Flutter NFC Development Guide

Cross-Platform NFC with nfc_manager

| 4 min read

Flutter NFC Development Guide: Cross-Platform with nfc_manager

Flutter's cross-platform story is compelling for NFC: write once, ship on Android and iOS from a single Dart codebase. The nfc_manager package by Naoki Okada is the de-facto standard, wrapping Android's NfcAdapter and iOS's Core NFC behind a unified API while still exposing platform-specific tech objects when you need raw access.

Package Setup

Add to pubspec.yaml:

dependencies:
  nfc_manager: ^3.3.0

Run flutter pub get. No additional Kotlin/Swift bridge code is required for basic NDEF operations.

Android: Add to AndroidManifest.xml inside <manifest>:

<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc" android:required="true" />

iOS: In ios/Runner/Info.plist, add:

<key>NFCReaderUsageDescription</key>
<string>Used to read and write NFC tags.</string>

Add the com.apple.developer.nfc.readersession.formats entitlement with value NDEF in ios/Runner/Runner.entitlements.

Availability Check

Always guard NFC calls with an availability check before presenting UI:

import 'package:nfc_manager/nfc_manager.dart';

Future<bool> isNfcAvailable() async {
  return await NfcManager.instance.isAvailable();
}

On iOS, isAvailable() returns false on simulators and iPads without NFC hardware. On Android it reflects whether NFC is enabled in Settings.

Reading an NDEF Tag

The session-based API mirrors iOS Core NFC's sheet modal on iOS and fires silently on Android:

Future<void> readTag() async {
  NfcManager.instance.startSession(
    onDiscovered: (NfcTag tag) async {
      final ndef = Ndef.from(tag);
      if (ndef == null) {
        // Tag is not NDEF-compatible
        NfcManager.instance.stopSession(errorMessage: 'Tag is not NDEF.');
        return;
      }
      final message = ndef.cachedMessage;
      if (message == null) {
        NfcManager.instance.stopSession(errorMessage: 'No NDEF message.');
        return;
      }
      for (final record in message.records) {
        _handleRecord(record);
      }
      NfcManager.instance.stopSession();
    },
  );
}

ndef.cachedMessage contains the message read during tag discovery — no extra round-trip required. Always call stopSession() in all code paths, including error branches; an open iOS session blocks the NFC sheet indefinitely.

Parsing NDEF Records

nfc_manager gives you raw NdefRecord objects. Parse ndef-uri and ndef-text records manually:

void _handleRecord(NdefRecord record) {
  // URI record: TNF = 0x01, type = [0x55]
  if (record.typeNameFormat == NdefTypeNameFormat.nfcWellknown &&
      record.type.isNotEmpty &&
      record.type[0] == 0x55) {
    final prefix = _uriPrefixes[record.payload[0]] ?? '';
    final uri = prefix + utf8.decode(record.payload.sublist(1));
    debugPrint('URI: $uri');
    return;
  }
  // Text record: TNF = 0x01, type = [0x54]
  if (record.typeNameFormat == NdefTypeNameFormat.nfcWellknown &&
      record.type.isNotEmpty &&
      record.type[0] == 0x54) {
    final langLen = record.payload[0] & 0x3F;
    final text = utf8.decode(record.payload.sublist(1 + langLen));
    debugPrint('Text: $text');
  }
}

const _uriPrefixes = {
  0x00: '', 0x01: 'http://www.', 0x02: 'https://www.',
  0x03: 'http://', 0x04: 'https://', 0x05: 'tel:', 0x06: 'mailto:',
};

Use the NDEF Decoder to verify your parsing logic against known byte sequences.

Writing an NDEF Message

Future<void> writeUrl(String url) async {
  NfcManager.instance.startSession(
    onDiscovered: (NfcTag tag) async {
      final ndef = Ndef.from(tag);
      if (ndef == null || !ndef.isWritable) {
        NfcManager.instance.stopSession(errorMessage: 'Tag not writable.');
        return;
      }
      final record = NdefRecord.createUri(Uri.parse(url));
      final message = NdefMessage([record]);
      try {
        await ndef.write(message);
        NfcManager.instance.stopSession(alertMessage: 'Written!');
      } catch (e) {
        NfcManager.instance.stopSession(errorMessage: e.toString());
      }
    },
  );
}

Check user-memory limits before writing large payloads. The Memory Calculator gives available bytes per chip family.

Platform-Specific Raw Access

For MIFARE operations or authentication beyond NDEF, access the tech objects directly:

// Android: MIFARE Ultralight raw page read
final mifareUltralight = MifareUltralight.from(tag);
if (mifareUltralight != null) {
  final pages = await mifareUltralight.readPages(pageOffset: 4);
}

// Android: IsoDep for ISO 14443-4 APDU commands
final isoDep = IsoDep.from(tag);
if (isoDep != null) {
  final response = await isoDep.transceive(data: Uint8List.fromList([0x00, 0xA4, 0x04, 0x00]));
}

iOS raw access is more restricted — Core NFC exposes NFCFeliCaTag, NFCMiFareTag, and NFCISO7816Tag through the nfc_manager iOS tech objects.

State Management Pattern

Wrap NFC state in a ChangeNotifier or Riverpod provider so the UI reacts to scan events without coupling to platform callbacks:

class NfcScanNotifier extends ChangeNotifier {
  String? _lastUri;
  bool _isScanning = false;
  String? get lastUri => _lastUri;
  bool get isScanning => _isScanning;

  Future<void> startScan() async {
    _isScanning = true;
    notifyListeners();
    NfcManager.instance.startSession(onDiscovered: (tag) async {
      final ndef = Ndef.from(tag);
      // ... parse ...
      _isScanning = false;
      notifyListeners();
      NfcManager.instance.stopSession();
    });
  }
}

Error Handling Reference

Exception Cause Fix
NfcManagerException(unavailable) NFC disabled or absent Show settings deeplink
NfcManagerException(sessionTimeout) iOS 60s timeout Restart session, show hint
IOException: Tag was lost Tag moved too fast Prompt user to hold steady
FormatException Corrupt NDEF on tag Re-format tag
TagLostException Android mid-write tag loss Retry with fresh tag

See also: iOS Core NFC Programming | Android NFC Programming | Web NFC API Guide | NDEF Specification Deep Dive

Terms in This Guide