Most Flutter NFC tutorials stop at "write a URL". When I built the encoding layer for an NFC app, I needed eleven record types, exact byte counts before writing, and records that every phone would read. Packages like nfc_manager give you the session and the tag; building the right bytes is still up to you.
This post walks through four record types — URI, text, vCard and Wi-Fi — at the byte level, with the Dart I ended up with.
A 30-second NDEF refresher
An NDEF message is a list of records. Each record has:
- a TNF (Type Name Format): well-known (NFC Forum types like
UandT), media (a MIME type), external, and so on - a type (e.g.
U, ortext/vcard) - an optional ID
- a payload
With ndef_record/nfc_manager_ndef, building one looks like this:
ndef.NdefRecord(
typeNameFormat: ndef.TypeNameFormat.wellKnown,
type: Uint8List.fromList([0x55]), // 'U'
identifier: Uint8List(0),
payload: payloadBytes,
);
The interesting part is always the payload.
1. URI records: the one-byte prefix trick
The NFC Forum URI record (U, 0x55) starts its payload with a prefix code: one byte that stands for a common URI start. 0x04 means https://, 0x02 means https://www., 0x05 means tel:, 0x06 means mailto: — 36 entries in all, index 0 being "no prefix".
On an NTAG213 with 144 bytes of user memory, saving 12 bytes on https://www. matters.
const uriPrefixList = [
'', 'http://www.', 'https://www.', 'http://', 'https://',
'tel:', 'mailto:', /* … the rest of the NFC Forum table … */
];
static ndef.NdefRecord encodeUri(String uri) {
var rest = uri;
var prefixIndex = 0;
for (var i = 1; i < uriPrefixList.length; i++) {
if (rest.startsWith(uriPrefixList[i])) {
prefixIndex = i;
rest = rest.substring(uriPrefixList[i].length);
break;
}
}
return ndef.NdefRecord(
typeNameFormat: ndef.TypeNameFormat.wellKnown,
type: Uint8List.fromList([0x55]),
identifier: Uint8List(0),
payload: Uint8List.fromList([prefixIndex, ...utf8.encode(rest)]),
);
}
Gotcha: order matters. https://www. must be checked before https://. The table's own order puts the www. variants first, so walking it in order does the right thing.
The same encoder handles phone (tel:), SMS (sms:), email (mailto:), social profiles and map locations (geo:lat,lng), because they're all just URIs.
2. Text records: a status byte you can't skip
Text records (T, 0x54) start with a status byte: bit 7 = encoding (0 for UTF-8), low 6 bits = length of the language code that follows. Then the language code, then the text.
static ndef.NdefRecord encodeText(String text) {
final lang = utf8.encode('en');
final status = lang.length; // bit 7 clear = UTF-8
return ndef.NdefRecord(
typeNameFormat: ndef.TypeNameFormat.wellKnown,
type: Uint8List.fromList([0x54]),
identifier: Uint8List(0),
payload: Uint8List.fromList([status, ...lang, ...utf8.encode(text)]),
);
}
If you forget the status byte, some readers show the first character of your text as garbage, and others drop the record entirely.
3. vCards: just a media record
Contact cards aren't an NFC Forum type. They're a media record whose type is the MIME string text/vcard and whose payload is the vCard text:
static ndef.NdefRecord encodeMedia(String mimeType, String data) {
return ndef.NdefRecord(
typeNameFormat: ndef.TypeNameFormat.media,
type: Uint8List.fromList(utf8.encode(mimeType)),
identifier: Uint8List(0),
payload: Uint8List.fromList(utf8.encode(data)),
);
}
That's simple to write, but vCards get big fast. A card with name, title, company, phone, email, website and a few social URLs easily passes 144 bytes, so it won't fit an NTAG213. NTAG215 (504 bytes) is the practical minimum. Because each record can be encoded before writing, the app can show the exact size and warn before a write fails halfway.
4. Wi-Fi: TLVs inside a MIME record
Wi-Fi credentials use the Wi-Fi Alliance's Simple Configuration format: a media record of type application/vnd.wfa.wsc whose payload is nested TLVs (2-byte type, 2-byte length, value, all big-endian).
The outer TLV is a Credential (0x100E) containing:
| TLV | Type | Value |
|---|---|---|
| Network Index | 0x1026 |
1 |
| SSID | 0x1045 |
network name, UTF-8 |
| Auth Type | 0x1003 |
0x0020 WPA2-Personal, 0x0001 open |
| Encryption Type | 0x100F |
0x0008 AES, 0x0002 WEP, 0x0001 none |
| Network Key | 0x1027 |
password (omitted for open networks) |
| MAC Address | 0x1020 |
FF FF FF FF FF FF (any) |
static List<int> _u16(int v) => [(v >> 8) & 0xff, v & 0xff];
static List<int> _tlv(int type, List<int> value) =>
[..._u16(type), ..._u16(value.length), ...value];
final credential = [
..._tlv(0x1026, [1]),
..._tlv(0x1045, utf8.encode(ssid)),
..._tlv(0x1003, _u16(0x0020)), // WPA2-Personal
..._tlv(0x100F, _u16(0x0008)), // AES
..._tlv(0x1027, utf8.encode(password)),
..._tlv(0x1020, List.filled(6, 0xff)),
];
final payload = _tlv(0x100E, credential);
Platform note: Android's NFC stack reads this record and offers to join the network. iPhones don't join Wi-Fi from NFC tags, so if your users include iPhone owners, pair the tag with a Wi-Fi QR code.
Count bytes before you write
Every encoder above returns a real NdefRecord, so NdefMessage(records: [...]).byteLength gives the exact size before you touch a tag. Compare that with the tag's capacity (the app reads it during the scan) and you can refuse an oversized write with a clear message, instead of a vague platform error halfway through.
Wrapping up
The NDEF spec is small enough to implement by hand, and doing so pays off. You control every byte, you can test encoders without hardware, and you can tell users exactly what fits on their tag.
I built this for NFC Wizard, an NFC toolkit for iPhone and Android - Coming Soon(disclosure: it's my app). If you're choosing tags for a project, I wrote a plain-language guide to NTAG213 vs NTAG215 vs NTAG216 and one on writing Wi-Fi to a tag.
Top comments (0)