---
title: How to Scan and Write NFC Tags with Capacitor
description: Scan NFC tags in your Capacitor and Ionic app with the Capacitor NFC plugin, from reading and writing NDEF records to raw commands and tag emulation.
date: 
  created: 2025-07-02
  updated: 2026-10-19
authors:
  - robingenz
categories:
  - Capacitor
  - Guides
  - SDKs
links:
  - Capacitor NFC: sdks/capacitor/nfc.md
faq: true
---

# How to Scan and Write NFC Tags with Capacitor

Scanning an NFC tag from a Capacitor app takes three calls: start a scan session, wait for the tag event, and stop the session. The [Capacitor NFC plugin](../../sdks/capacitor/nfc.md) from Capawesome wraps the native NFC APIs of Android, iOS and Web behind that one flow, and adds NDEF reading and writing, raw commands, and Host Card Emulation. This guide walks through a complete scan, parses the NDEF records that come back, writes a record of your own, and covers what differs per platform and what goes wrong on real devices.

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Try Capawesome Cloud free for 14 days — iOS builds without a Mac" src="https://capawesome.io/assets/banners/cloud-free-trial-capacitor-apps.png" />
  </a>
</div>

**Key takeaways:**

- The plugin is published as `@capawesome-team/capacitor-nfc` and is part of Capawesome Insiders; it supports Android, iOS and Web with one API.
- A scan is `startScanSession()`, one `nfcTagScanned` event, then `stopScanSession()`; only one session can be active at a time.
- Android and iOS need no runtime permission; NFC access is declared in the Android manifest and the iOS entitlements, and `requestPermissions()` only matters on Web.
- The plugin ships an `NfcUtils` class that builds and parses NDEF text and URI records.
- Emulators and simulators have no NFC hardware. Every scan has to be tested on a physical device.

## How NFC works

NFC is a short-range radio link. The phone powers a passive tag over a field of a few centimeters, reads the data stored on it, and can write data back. The data on a tag is stored as NDEF, and Host Card Emulation turns the roles around so that the phone answers a reader instead. Four terms cover most of what follows:

- **NFC tag**: a passive chip that stores a few hundred bytes to a few kilobytes and answers when a reader comes within a few centimeters.
- **NFC reader**: the device that powers and reads the tag. In this guide that is the phone running your app.
- **NDEF**: the NFC Data Exchange Format, a standard container for records such as text, URIs and MIME payloads. Most tags you buy are NDEF-formatted.
- **Host Card Emulation (HCE)**: a mode in which the phone itself behaves like a tag and answers commands from an external reader.

## Checking the device

To install the Capacitor NFC plugin, please refer to the [Installation](../../sdks/capacitor/nfc.md/#installation) section in the plugin documentation. It also lists the Android manifest entries and the iOS entitlement that every example below depends on.

Check NFC support and status before starting a session, because a device without NFC never delivers a tag. The [`isSupported()`](../../sdks/capacitor/nfc.md#issupported) method reports whether the hardware exists. The [`isEnabled()`](../../sdks/capacitor/nfc.md#isenabled) method reports whether the user has switched it on:

```ts
import { Nfc } from '@capawesome-team/capacitor-nfc';
import { Capacitor } from '@capacitor/core';

const checkDevice = async (): Promise<boolean> => {
  const { nfc } = await Nfc.isSupported();
  if (!nfc) {
    alert('This device has no NFC hardware.');
    return false;
  }
  const { isEnabled } = await Nfc.isEnabled();
  if (!isEnabled && Capacitor.getPlatform() === 'android') {
    await Nfc.openSettings();
    return false;
  }
  return isEnabled;
};
```

Both methods return an object. Destructure the `nfc` and `isEnabled` properties instead of treating the result as a boolean. The `openSettings()` call opens the NFC page of the Android settings app. iOS has no global NFC switch, and there `isEnabled()` reflects whether NFC reading is available on the device.

The same `isSupported()` call also returns an `hce` flag that tells you whether the device can emulate a card, which only Android devices do.

Permissions are a Web-only concern. On Android and iOS the `checkPermissions()` and [`requestPermissions()`](../../sdks/capacitor/nfc.md#requestpermissions) methods always return `granted`, because access is declared at build time in the manifest and the entitlements file. In a browser, call `requestPermissions()` once before the first scan so Chrome can show its permission prompt:

```ts
const requestWebPermission = async (): Promise<boolean> => {
  const { nfc } = await Nfc.requestPermissions();
  return nfc === 'granted';
};
```

## Scanning an NFC tag

A scan session is a short-lived listener for one or more tags. You register the [`nfcTagScanned`](../../sdks/capacitor/nfc.md#addlistenernfctagscanned) listener first. Then you call [`startScanSession()`](../../sdks/capacitor/nfc.md#startscansession), and the operating system starts polling. On iOS this opens the system "Ready to Scan" sheet, and the `alertMessage` option sets the text in that sheet. The example below wraps a single scan in a promise and also listens for the two ways a session can end without a tag:

```ts
import { Nfc } from '@capawesome-team/capacitor-nfc';
import type { NfcTag } from '@capawesome-team/capacitor-nfc';

const scanTag = async (): Promise<NfcTag> => {
  await Nfc.removeAllListeners();
  return new Promise((resolve, reject) => {
    Nfc.addListener('nfcTagScanned', async (event) => {
      await Nfc.stopScanSession();
      resolve(event.nfcTag);
    });
    Nfc.addListener('scanSessionCanceled', () => {
      reject(new Error('The user closed the NFC sheet.'));
    });
    Nfc.addListener('scanSessionError', async (event) => {
      await Nfc.stopScanSession();
      reject(new Error(event.message));
    });
    Nfc.startScanSession({
      alertMessage: 'Hold your iPhone near the tag.',
    }).catch(reject);
  });
};
```

The `nfcTag` object in the event carries the tag identifier in `id`, the detected technologies in `techTypes`, the `isWritable` and `maxSize` fields for NDEF tags on Android and iOS, and the NDEF message itself in `message`. Properties that only one platform exposes, such as `atqa` or `sak` on Android, are listed per platform in the [`NfcTag`](../../sdks/capacitor/nfc.md#nfctag) interface.

Call [`stopScanSession()`](../../sdks/capacitor/nfc.md#stopscansession) as soon as you have what you need. On iOS that closes the sheet; on Android it stops the reader mode that would otherwise keep intercepting tags. The `errorMessage` option of `stopScanSession()` shows a failure text in the iOS sheet instead of the default success state, for example when the tag is not the one you expected. While the sheet is open, [`setAlertMessage(...)`](../../sdks/capacitor/nfc.md#setalertmessage) changes its text, for example to "Writing..." during a write.

On Android, pass `techTypes` to `startScanSession()` to ignore tags your app cannot handle, for example `[NfcTagTechType.Ndef]` when you only care about NDEF content, or `mimeTypes` to filter NDEF tags by the media type they carry. On iOS, the `pollingOptions` field selects the tag families to poll for.

## Managing the session

Only one scan session can be active at a time. A second `startScanSession()` while one is running does not start another. Tie the session to a user action and to the screen that owns it: start it when the user taps "Scan", stop it in the `nfcTagScanned` listener, and stop it again when the page is left or the app goes to the background.

Listeners are not removed when the session stops. The handle returned by `addListener(...)` has a `remove()` method, and [`removeAllListeners()`](../../sdks/capacitor/nfc.md#removealllisteners) clears everything the plugin holds. The examples in this guide call `removeAllListeners()` before registering new listeners. Without that, a page that is opened twice resolves the same promise twice.

On iOS the session can end without a tag when the user dismisses the sheet, which fires `scanSessionCanceled`, or when Core NFC ends the session on its own, since Apple limits a [tag reader session](https://developer.apple.com/documentation/corenfc/nfctagreadersession){:target="_blank"} to 60 seconds. Errors during a session reach your app through [`scanSessionError`](../../sdks/capacitor/nfc.md#addlistenerscansessionerror). Handle both events, or the promise in `scanTag()` never settles. Android has no cancel event, because there is no sheet to dismiss.

## Reading NDEF records

Every record in `nfcTag.message.records` carries its payload as a byte array, and the `tnf` and `type` fields tell you how to decode it. The `NfcUtils` class handles the status byte of a text record and the prefix code of a URI record:

```ts
import { NfcUtils, RecordTypeDefinition, TypeNameFormat } from '@capawesome-team/capacitor-nfc';
import type { NdefRecord } from '@capawesome-team/capacitor-nfc';

const utils = new NfcUtils();

const describeRecord = (record: NdefRecord): string => {
  if (record.tnf === TypeNameFormat.WellKnown) {
    const { type } = utils.mapBytesToRecordTypeDefinition({ bytes: record.type });
    if (type === RecordTypeDefinition.Text) {
      const { text } = utils.getTextFromNdefTextRecord({ record });
      const { language } = utils.getLanguageFromNdefTextRecord({ record });
      return `Text (${language}): ${text}`;
    }
    if (type === RecordTypeDefinition.Uri) {
      const { uri } = utils.getUriFromNdefUriRecord({ record });
      return `URI: ${uri}`;
    }
  }
  if (record.tnf === TypeNameFormat.MimeMedia) {
    const { text: mimeType } = utils.convertBytesToString({ bytes: record.type });
    return `MIME ${mimeType}, ${record.payload.length} bytes`;
  }
  return `Unhandled record, ${record.payload.length} bytes`;
};

const readTag = async () => {
  const tag = await scanTag();
  const records = tag.message?.records ?? [];
  return records.map(describeRecord);
};
```

A tag written with the text "Capacitor NFC Plugin" and the URL of the plugin docs returns an array like this:

```ts
[
  'Text (en): Capacitor NFC Plugin',
  'URI: https://capawesome.io/docs/sdks/capacitor/nfc/',
]
```

For well-known records the `type` field holds the record type definition, which `mapBytesToRecordTypeDefinition(...)` turns into a `RecordTypeDefinition` value such as `Text` or `Uri`. For MIME records it is the media type, such as `application/json`, and the payload is the raw body, which you can turn into a string with `convertBytesToString(...)`. A tag that carries no NDEF message, such as a blank MIFARE Ultralight, arrives without one, so guard for `undefined` before reading `records`.

## Writing NDEF records

Writing happens inside a scan session as well: the tag has to be in the field when you call [`write(...)`](../../sdks/capacitor/nfc.md#write). Build the records with the same `NfcUtils` class, then write them from the `nfcTagScanned` listener:

```ts
import { Nfc, NfcUtils } from '@capawesome-team/capacitor-nfc';

const writeTag = async (): Promise<void> => {
  const utils = new NfcUtils();
  const { record: text } = utils.createNdefTextRecord({ text: 'Capacitor NFC Plugin' });
  const { record: uri } = utils.createNdefUriRecord({ uri: 'https://capawesome.io/docs/sdks/capacitor/nfc/' });

  await Nfc.removeAllListeners();
  return new Promise((resolve, reject) => {
    Nfc.addListener('nfcTagScanned', async (event) => {
      if (event.nfcTag.isWritable === false) {
        await Nfc.stopScanSession({ errorMessage: 'This tag is read-only.' });
        reject(new Error('Tag is not writable.'));
        return;
      }
      await Nfc.write({ message: { records: [text, uri] } });
      await Nfc.stopScanSession();
      resolve();
    });
    Nfc.startScanSession().catch(reject);
  });
};
```

The `isWritable` and `maxSize` properties are only set on Android and iOS. On Web both are `undefined`, which is why the example compares `isWritable` against `false` instead of negating it. The `maxSize` property is the largest NDEF message the tag accepts, in bytes, so check it before writing a longer MIME payload. The announcement post lists the memory of the common NTAG chips under [Which tags work](./announcing-the-capacitor-nfc-plugin.md#which-tags-work).

Three more helpers cover the other record types:

- `createNdefMimeMediaRecord(...)` wraps a payload with a media type, such as `application/json`.
- `createNdefExternalRecord(...)` builds a record with a type under your own domain, such as `example.com:asset`.
- `createNdefAbsoluteUriRecord(...)` builds a record whose type is a URI itself.

The [Utils](../../sdks/capacitor/nfc.md#utils) reference lists all of them.

A URI record is also how a tag launches your app. On Android, an intent filter in the manifest matches the URI and opens the app; on iOS, the URI has to be a universal link of your app or one of the URL schemes that Apple lists under [background tag reading](https://developer.apple.com/documentation/corenfc/adding_support_for_background_tag_reading){:target="_blank"}. Both configurations are described in the Installation section of the docs.

## Locking and erasing

Three more methods change the state of a tag during a session. [`makeReadOnly()`](../../sdks/capacitor/nfc.md#makereadonly) write-protects it permanently; on Android the `canMakeReadOnly` property of the scanned tag tells you beforehand whether the tag supports that. `erase()` writes an empty NDEF message over the existing one, and `format()`, on Android only, formats a tag that is not NDEF-formatted yet. Each call sits in the `nfcTagScanned` listener in the same position as `write(...)` above.

???+ warning

    `makeReadOnly()` is a one-way operation. Once a tag is locked, no app can write to it again. Put the call behind a confirmation dialog.

## Sending raw commands

Tags that store data outside NDEF, such as access cards, answer to commands from their own specification instead. The [`transceive(...)`](../../sdks/capacitor/nfc.md#transceive) method sends a byte array and returns the response. On Android you connect to one technology of the tag first and close the connection afterwards; on iOS the technology is a parameter of the call:

```ts
import { Nfc, NfcTagTechType } from '@capawesome-team/capacitor-nfc';
import { Capacitor } from '@capacitor/core';

const readSignature = async (): Promise<number[]> => {
  await Nfc.removeAllListeners();
  return new Promise((resolve, reject) => {
    Nfc.addListener('nfcTagScanned', async () => {
      const isAndroid = Capacitor.getPlatform() === 'android';
      if (isAndroid) {
        await Nfc.connect({ techType: NfcTagTechType.MifareUltralight });
      }
      const { response } = await Nfc.transceive({
        techType: NfcTagTechType.MifareUltralight,
        data: [0x3c, 0x00],
      });
      if (isAndroid) {
        await Nfc.close();
      }
      await Nfc.stopScanSession();
      resolve(response);
    });
    Nfc.startScanSession().catch(reject);
  });
};
```

The command `0x3C 0x00` is `READ_SIG` from the [NTAG213/215/216 data sheet](https://www.nxp.com/docs/en/data-sheet/NTAG213_215_216.pdf){:target="_blank"} and returns the 32-byte originality signature of the chip. `MifareUltralight` is the technology both platforms list for that chip family. Pick the technology from the `techTypes` array of the scanned tag, and check the platform notes of the [`NfcTagTechType`](../../sdks/capacitor/nfc.md#nfctagtechtype) enum before you call `transceive(...)`.

The plugin marks `transceive(...)` as experimental, and a wrong command can damage a tag for good, so take every command from the data sheet of the tag you hold. Data sheets list commands in hex, and the `convertHexToBytes(...)` helper turns a hex string into the byte array the plugin expects.

???+ info

    For NFC-V tags (ISO 15693) on iOS, the `iso15693RequestFlags` and `iso15693CommandCode` options of `transceive(...)` carry the request flags and the manufacturer command code that those commands need.

## Emulating a card

Host Card Emulation turns an Android phone into the tag. An external reader sends APDU commands, and the plugin delivers each one through the [`commandReceived`](../../sdks/capacitor/nfc.md#addlistenercommandreceived) event. Your app answers with [`respond(...)`](../../sdks/capacitor/nfc.md#respond), and no scan session is involved, because the reader starts the exchange:

```ts
import { Nfc } from '@capawesome-team/capacitor-nfc';

const emulateCard = async () => {
  await Nfc.addListener('commandReceived', async (event) => {
    const isSelect = event.data[1] === 0xa4;
    const payload = isSelect ? [] : [0x48, 0x69];
    await Nfc.respond({ data: [...payload, 0x90, 0x00] });
  });
  await Nfc.addListener('nfcLinkDeactivated', (event) => {
    console.log('Reader left:', event.reason);
  });
};
```

The `0x90 0x00` trailer is the ISO 7816 status word for success. The `nfcLinkDeactivated` event fires when the link is deactivated or lost, and its `reason` also tells you when the reader selected a different application, which is where you reset any state the exchange built up. HCE needs the APDU service declared in the Android manifest, described under Services in the Installation section of the docs, and it has no iOS equivalent.

???+ info

    Only raw APDU commands cross the link during emulation. The NDEF helpers do not apply, because the phone is not an NDEF tag from the reader's point of view.

## Platform differences

The same scan code runs on all three platforms, but the operating systems behave differently around it. The table lists what changes:

| Behavior | Android | iOS | Web |
|---|---|---|---|
| Scan UI | None; the app keeps polling in the background of its own screen | System "Ready to Scan" sheet; text from `alertMessage` | Browser permission prompt on first use |
| Runtime permission | Not needed | Not needed | `requestPermissions()` |
| Enable NFC | `openSettings()` opens the NFC settings | No global switch | Not applicable |
| Raw commands | `connect()` → `transceive()` → `close()` | `transceive()` with `techType` | Not supported |
| Host Card Emulation | Supported; `isSupported().hce` | Not supported | Not supported |
| Antenna position | `getAntennaInfo()` | Not available | Not available |
| Session canceled event | Not fired | `scanSessionCanceled` when the sheet is dismissed | Not fired |

The Web implementation builds on the [Web NFC API](https://developer.mozilla.org/en-US/docs/Web/API/Web_NFC_API){:target="_blank"}, which Chrome on Android supports and desktop browsers and Safari do not. A Web build is mostly a development aid.

On Android, [`getAntennaInfo()`](../../sdks/capacitor/nfc.md#getantennainfo) returns the device size and the position of each NFC antenna, so a scanning screen can draw a marker where the tag belongs. Foldables report `isDeviceFoldable` for adjusting the marker per posture. On iOS, the system sheet tells the user where to hold the device.

## Troubleshooting

If `startScanSession()` rejects before any tag shows up, run the device checks above first and show the user the result instead of a stack trace.

An iOS session that fails immediately with `The connection to service named com.apple.nfcd.service.corenfc was invalidated from this process` means the Near Field Communication Tag Reading capability is missing from the app target. Add it, together with the NFC Scan capability, in Xcode under Signing & Capabilities and confirm that the entitlements file lists both the `NDEF` and `TAG` formats, as shown in the docs. The Xcode change needs a new native build, so a live update cannot fix it.

Emulators and simulators have no NFC hardware. `isSupported()` returns `false` there, and no configuration changes that. Testing needs a physical Android phone and an iPhone with NFC. A blank tag that triggers `nfcTagScanned` but carries no `message` is not an error either: write to it first, or call `format()` on Android if it is not NDEF-formatted.

If a scan on iOS returns nothing while the sheet is open, the tag type may be outside the default `pollingOptions`, which cover ISO 14443 and ISO 15693. Add `PollingOption.iso18092` for FeliCa tags, or enable `compatibilityMode`, which switches to the NDEF-only reader session for tags that only answer there.

## FAQ

### Do I need to request NFC permission on Android or iOS?

No. Access is declared at build time: the `uses-feature` element in the Android manifest and the Near Field Communication Tag Reading and NFC Scan capabilities plus the `NFCReaderUsageDescription` key on iOS. The `checkPermissions()` and `requestPermissions()` methods return `granted` on both platforms and only prompt in a browser.

### Can I test NFC scanning in an emulator or simulator?

No. The Android emulator and the iOS simulator have no NFC hardware. `isSupported()` returns `false` and `startScanSession()` fails. Test on a physical device with NFC.

### How do I open my app by scanning a tag?

Write a URI record to the tag. On Android, add an intent filter for that URI to the manifest; on iOS, the URI must be a universal link of your app or a URL scheme that Core NFC supports for background tag reading. The [Installation](../../sdks/capacitor/nfc.md/#installation) section of the docs lists both configurations.

### Can my iOS app emulate an NFC tag like it can on Android?

No. Host Card Emulation is Android-only. The `respond(...)` method and the `commandReceived` event are not available on iOS, so an app that has to act as a card for other readers can only do that on Android devices.

### Can I write NDEF data during tag emulation?

No. During HCE, only raw APDU commands are exchanged with the reader. NDEF reading and writing apply to physical tags your app scans, not to the phone when it is emulating a card.

### Does `getAntennaInfo()` work on iOS?

No, it is Android-only. iOS offers no API for the antenna position. A scanning screen that guides the user to the antenna only works on Android. On iOS, the system sheet already asks the user to hold the device near the tag.

### Do I need `connect()` before `transceive()` on both platforms?

No, only on Android. There you connect to one technology with `connect()`, send commands with `transceive()`, and release it with `close()`. On iOS, `transceive()` takes the `techType` directly and needs no connect and close calls.

### Can I send users to the NFC settings on both platforms?

Only on Android. `openSettings()` opens the NFC page of the Android settings app. iOS has no NFC switch and no settings page to open, so `isEnabled()` there reflects whether the device supports NFC reading at all.

## Try Capawesome Cloud

The NFC capability on iOS and the manifest entries on Android are native changes. Each one needs a new native build before the first scan works. With Capawesome Cloud, that build runs in the cloud for both platforms, including iOS without a Mac, and [How to Sign & Build Capacitor Apps in the Cloud](./how-to-sign-and-build-your-capacitor-app-in-the-cloud.md) shows the steps.

[Try Capawesome Cloud Free](https://capawesome.io){ .md-button .md-button--primary }

## Conclusion

Start every NFC feature with `isSupported()` and `isEnabled()`, and keep one session per user action. If the app has to act as a card for other readers, plan for Android only. The [API Reference](../../sdks/capacitor/nfc.md#api) lists every method, option and event of the Capacitor NFC plugin.

**Related posts:**

- [Announcing the Capacitor NFC Plugin](./announcing-the-capacitor-nfc-plugin.md)
- [Announcing the Capacitor Bluetooth Low Energy Plugin](./announcing-the-capacitor-bluetooth-low-energy-plugin.md), for tap-to-pair flows that read a device identifier from a tag

If you have any questions about the Capacitor NFC plugin, [reach out](mailto:support@capawesome.io) to the Capawesome team.

Subscribe to the [Capawesome newsletter](https://capawesome.io/newsletter/){:target="_blank"} and join the [Capawesome Discord server](https://discord.gg/VCXxSVjefW){:target="_blank"} for questions and updates on Ionic and Capacitor.
