Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result
  1. Check reading capability
  2. Start a foreground session
  3. Read supported tag data
  4. Invalidate the session
02 / ArchitectureResponsibility boundaries
  1. Boundary 1Reader session
  2. Boundary 2NFC tag
  3. Boundary 3Validated domain data

Connected responsibilities, not a required class hierarchy or an execution trace.

NFCNDEFReaderSession Setup and Entitlements

Requirements: - iPhone 7 or later - Add Near Field Communication Tag Reading capability in Xcode - Add NFCReaderUsageDescription to Info.plist - Add NFC entitlement to your provisioning profile

Info.plist entries:

<key>NFCReaderUsageDescription</key>
<string>This app reads NFC tags to retrieve information.</string>

<!-- For background tag reading -->
<key>com.apple.developer.associated-application-identifier</key>
<string>$(TeamIdentifierPrefix)com.example.app</string>

Entitlements file (.entitlements):

<key>com.apple.developer.nfc.readersession.formats</key>
<array>
    <string>NDEF</string>
    <string>TAG</string>
</array>
import CoreNFC

@Observable
class NFCManager: NSObject {
    var scannedMessage: String = ""
    var scannedRecords: [NFCRecord] = []
    var isScanning = false
    var error: Error?

    private var ndefSession: NFCNDEFReaderSession?
    private var writeMessage: NFCNDEFMessage?

    var isNFCAvailable: Bool {
        NFCNDEFReaderSession.readingAvailable
    }
}

struct NFCRecord: Identifiable {
    let id = UUID()
    let type: String
    let payload: String
    let rawData: Data
}

Reading NDEF Tags

extension NFCManager: NFCNDEFReaderSessionDelegate {
    func startReading() {
        guard isNFCAvailable else {
            error = NFCError.notAvailable
            return
        }

        ndefSession = NFCNDEFReaderSession(
            delegate: self,
            queue: nil,
            invalidateAfterFirstRead: true
        )
        ndefSession?.alertMessage = "Hold your iPhone near an NFC tag."
        ndefSession?.begin()
        isScanning = true
    }

    // MARK: - NFCNDEFReaderSessionDelegate

    func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
        // Session is active and scanning
    }

    func readerSession(
        _ session: NFCNDEFReaderSession,
        didDetectNDEFs messages: [NFCNDEFMessage]
    ) {
        var records: [NFCRecord] = []

        for message in messages {
            for record in message.records {
                let parsed = parseNDEFRecord(record)
                records.append(parsed)
            }
        }

        Task { @MainActor in
            self.scannedRecords = records
            self.scannedMessage = records.map(\.payload).joined(separator: "\n")
            self.isScanning = false
        }
    }

    func readerSession(
        _ session: NFCNDEFReaderSession,
        didInvalidateWithError error: Error
    ) {
        let readerError = error as? NFCReaderError

        Task { @MainActor in
            // User cancelled is not a real error
            if readerError?.code != .readerSessionInvalidationErrorUserCanceled {
                self.error = error
            }
            self.isScanning = false
        }
    }
}

Parsing NDEF Payloads — URL, Text, Custom

extension NFCManager {
    func parseNDEFRecord(_ record: NFCNDEFPayload) -> NFCRecord {
        let typeString = String(data: record.type, encoding: .utf8) ?? "Unknown"

        switch record.typeNameFormat {
        case .nfcWellKnown:
            return parseWellKnownRecord(record, typeString: typeString)
        case .media:
            let mimeType = typeString
            let payload = String(data: record.payload, encoding: .utf8) ?? ""
            return NFCRecord(type: mimeType, payload: payload, rawData: record.payload)
        case .absoluteURI:
            let uri = String(data: record.payload, encoding: .utf8) ?? ""
            return NFCRecord(type: "URI", payload: uri, rawData: record.payload)
        case .nfcExternal:
            let payload = String(data: record.payload, encoding: .utf8) ?? ""
            return NFCRecord(type: "External: \(typeString)", payload: payload, rawData: record.payload)
        default:
            return NFCRecord(
                type: "Unknown",
                payload: record.payload.map { String(format: "%02x", $0) }.joined(),
                rawData: record.payload
            )
        }
    }

    private func parseWellKnownRecord(
        _ record: NFCNDEFPayload,
        typeString: String
    ) -> NFCRecord {
        switch typeString {
        case "T":
            // Text record
            return parseTextRecord(record)
        case "U":
            // URI record
            return parseURIRecord(record)
        default:
            let payload = String(data: record.payload, encoding: .utf8) ?? ""
            return NFCRecord(type: typeString, payload: payload, rawData: record.payload)
        }
    }

    private func parseTextRecord(_ record: NFCNDEFPayload) -> NFCRecord {
        let payload = record.payload
        guard !payload.isEmpty else {
            return NFCRecord(type: "Text", payload: "", rawData: payload)
        }

        let statusByte = payload[0]
        let languageCodeLength = Int(statusByte & 0x3F)
        let isUTF16 = (statusByte & 0x80) != 0

        let textStartIndex = 1 + languageCodeLength
        guard textStartIndex < payload.count else {
            return NFCRecord(type: "Text", payload: "", rawData: payload)
        }

        let textData = payload.subdata(in: textStartIndex..<payload.count)
        let encoding: String.Encoding = isUTF16 ? .utf16 : .utf8
        let text = String(data: textData, encoding: encoding) ?? ""

        return NFCRecord(type: "Text", payload: text, rawData: payload)
    }

    private func parseURIRecord(_ record: NFCNDEFPayload) -> NFCRecord {
        // Use Apple's built-in helper
        if let url = record.wellKnownTypeURIPayload() {
            return NFCRecord(type: "URL", payload: url.absoluteString, rawData: record.payload)
        }

        // Manual parsing fallback
        let prefixes = [
            0x00: "",
            0x01: "http://www.",
            0x02: "https://www.",
            0x03: "http://",
            0x04: "https://",
            0x05: "tel:",
            0x06: "mailto:"
        ]

        let payload = record.payload
        guard !payload.isEmpty else {
            return NFCRecord(type: "URL", payload: "", rawData: payload)
        }

        let prefix = prefixes[Int(payload[0])] ?? ""
        let remainder = String(data: payload.subdata(in: 1..<payload.count), encoding: .utf8) ?? ""

        return NFCRecord(type: "URL", payload: prefix + remainder, rawData: payload)
    }
}

Writing to NDEF Tags

extension NFCManager {
    func startWriting(text: String) {
        guard isNFCAvailable else {
            error = NFCError.notAvailable
            return
        }

        // Create NDEF message to write
        guard let textPayload = NFCNDEFPayload.wellKnownTypeTextPayload(
            string: text,
            locale: .current
        ) else {
            error = NFCError.invalidPayload
            return
        }

        writeMessage = NFCNDEFMessage(records: [textPayload])

        ndefSession = NFCNDEFReaderSession(
            delegate: self,
            queue: nil,
            invalidateAfterFirstRead: false  // false to allow writing
        )
        ndefSession?.alertMessage = "Hold your iPhone near an NFC tag to write."
        ndefSession?.begin()
        isScanning = true
    }

    func startWritingURL(_ url: URL) {
        guard isNFCAvailable else {
            error = NFCError.notAvailable
            return
        }

        guard let urlPayload = NFCNDEFPayload.wellKnownTypeURIPayload(url: url) else {
            error = NFCError.invalidPayload
            return
        }

        writeMessage = NFCNDEFMessage(records: [urlPayload])

        ndefSession = NFCNDEFReaderSession(
            delegate: self,
            queue: nil,
            invalidateAfterFirstRead: false
        )
        ndefSession?.alertMessage = "Hold your iPhone near an NFC tag to write."
        ndefSession?.begin()
        isScanning = true
    }

    // Called when a writable tag is detected
    func readerSession(
        _ session: NFCNDEFReaderSession,
        didDetect tags: [any NFCNDEFTag]
    ) {
        guard let tag = tags.first else {
            session.invalidate(errorMessage: "No tag found.")
            return
        }

        session.connect(to: tag) { [weak self] connectionError in
            guard connectionError == nil, let self else {
                session.invalidate(errorMessage: "Connection failed.")
                return
            }

            tag.queryNDEFStatus { status, capacity, error in
                guard error == nil else {
                    session.invalidate(errorMessage: "Failed to query tag.")
                    return
                }

                switch status {
                case .notSupported:
                    session.invalidate(errorMessage: "Tag is not NDEF formatted.")
                case .readOnly:
                    session.invalidate(errorMessage: "Tag is read-only.")
                case .readWrite:
                    guard let message = self.writeMessage else {
                        session.invalidate(errorMessage: "No message to write.")
                        return
                    }

                    // Check capacity
                    let messageLength = message.length
                    guard messageLength <= capacity else {
                        session.invalidate(
                            errorMessage: "Message too large for tag (\(messageLength)/\(capacity) bytes)."
                        )
                        return
                    }

                    tag.writeNDEF(message) { writeError in
                        if let writeError {
                            session.invalidate(errorMessage: "Write failed: \(writeError.localizedDescription)")
                        } else {
                            session.alertMessage = "Successfully wrote to tag!"
                            session.invalidate()
                        }
                    }
                @unknown default:
                    session.invalidate(errorMessage: "Unknown tag status.")
                }
            }
        }
    }
}

NFCTagReaderSession — ISO 14443, ISO 15693, FeliCa

@Observable
class NFCTagReader: NSObject, NFCTagReaderSessionDelegate {
    var tagID: String = ""
    var tagType: String = ""

    private var tagSession: NFCTagReaderSession?

    func startTagReading(pollingOption: NFCTagReaderSession.PollingOption = .iso14443) {
        guard NFCTagReaderSession.readingAvailable else { return }

        tagSession = NFCTagReaderSession(
            pollingOption: pollingOption,
            delegate: self,
            queue: nil
        )
        tagSession?.alertMessage = "Hold your iPhone near the tag."
        tagSession?.begin()
    }

    func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {}

    func tagReaderSession(
        _ session: NFCTagReaderSession,
        didInvalidateWithError error: Error
    ) {}

    func tagReaderSession(
        _ session: NFCTagReaderSession,
        didDetect tags: [NFCTag]
    ) {
        guard let tag = tags.first else { return }

        session.connect(to: tag) { error in
            guard error == nil else {
                session.invalidate(errorMessage: "Connection failed.")
                return
            }

            switch tag {
            case .iso7816(let iso7816Tag):
                // ISO 14443 Type A/B — used by payment cards, passports
                let identifier = iso7816Tag.identifier
                    .map { String(format: "%02x", $0) }
                    .joined(separator: ":")

                Task { @MainActor in
                    self.tagID = identifier
                    self.tagType = "ISO 7816 (ISO 14443)"
                }

                // Send APDU command
                let apdu = NFCISO7816APDU(
                    instructionClass: 0x00,
                    instructionCode: 0xB0,
                    p1Parameter: 0x00,
                    p2Parameter: 0x00,
                    data: Data(),
                    expectedResponseLength: 256
                )
                iso7816Tag.sendCommand(apdu: apdu) { responseData, sw1, sw2, error in
                    // Process response
                    session.alertMessage = "Tag read successfully."
                    session.invalidate()
                }

            case .iso15693(let iso15693Tag):
                // ISO 15693 — NFC-V, vicinity cards
                let identifier = iso15693Tag.identifier
                    .map { String(format: "%02x", $0) }
                    .joined(separator: ":")

                Task { @MainActor in
                    self.tagID = identifier
                    self.tagType = "ISO 15693"
                }
                session.alertMessage = "Tag read successfully."
                session.invalidate()

            case .feliCa(let feliCaTag):
                // FeliCa — used in Japan (Suica, etc.)
                let idm = feliCaTag.currentIDm
                    .map { String(format: "%02x", $0) }
                    .joined(separator: ":")

                Task { @MainActor in
                    self.tagID = idm
                    self.tagType = "FeliCa"
                }
                session.alertMessage = "Tag read successfully."
                session.invalidate()

            case .miFare(let miFareTag):
                // MiFare — NXP tags
                let identifier = miFareTag.identifier
                    .map { String(format: "%02x", $0) }
                    .joined(separator: ":")

                Task { @MainActor in
                    self.tagID = identifier
                    self.tagType = "MiFare (\(miFareTag.mifareFamily))"
                }
                session.alertMessage = "Tag read successfully."
                session.invalidate()

            @unknown default:
                session.invalidate(errorMessage: "Unsupported tag type.")
            }
        }
    }
}

Background Tag Reading

Add to Info.plist for Universal Links-style NFC launch:

<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:example.com</string>
</array>
import SwiftUI

// Handle background tag reading in your App
@main
struct NFCApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onContinueUserActivity(
                    NSUserActivityTypeBrowsingWeb
                ) { userActivity in
                    // Handle NFC tag scanned in background
                    guard let url = userActivity.webpageURL else { return }
                    handleNFCURL(url)
                }
        }
    }

    func handleNFCURL(_ url: URL) {
        // Process the URL from the NFC tag
        // This is called when the user taps the NFC notification
    }
}

Error Handling

enum NFCError: LocalizedError {
    case notAvailable
    case invalidPayload
    case tagNotWritable
    case messageTooLarge(needed: Int, capacity: Int)
    case connectionFailed
    case readFailed(Error)
    case writeFailed(Error)

    var errorDescription: String? {
        switch self {
        case .notAvailable:
            return "NFC is not available on this device."
        case .invalidPayload:
            return "The NFC payload is invalid."
        case .tagNotWritable:
            return "This NFC tag is read-only."
        case .messageTooLarge(let needed, let capacity):
            return "Message (\(needed) bytes) exceeds tag capacity (\(capacity) bytes)."
        case .connectionFailed:
            return "Failed to connect to the NFC tag."
        case .readFailed(let error):
            return "Read failed: \(error.localizedDescription)"
        case .writeFailed(let error):
            return "Write failed: \(error.localizedDescription)"
        }
    }
}

Complete NFC Tag Reader/Writer Example

import SwiftUI
import CoreNFC

struct NFCReaderWriterView: View {
    @State private var nfcManager = NFCManager()
    @State private var writeText = ""
    @State private var writeURL = ""
    @State private var selectedMode: NFCMode = .read

    enum NFCMode: String, CaseIterable {
        case read = "Read"
        case writeText = "Write Text"
        case writeURL = "Write URL"
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                // Mode picker
                Picker("Mode", selection: $selectedMode) {
                    ForEach(NFCMode.allCases, id: \.self) { mode in
                        Text(mode.rawValue).tag(mode)
                    }
                }
                .pickerStyle(.segmented)
                .padding(.horizontal)

                switch selectedMode {
                case .read:
                    readModeView
                case .writeText:
                    writeTextView
                case .writeURL:
                    writeURLView
                }

                Spacer()

                // NFC availability indicator
                if !nfcManager.isNFCAvailable {
                    Label("NFC not available on this device", systemImage: "xmark.circle")
                        .foregroundStyle(.red)
                        .font(.caption)
                }
            }
            .navigationTitle("NFC Tags")
        }
    }

    var readModeView: some View {
        VStack(spacing: 20) {
            Image(systemName: "wave.3.right")
                .font(.system(size: 60))
                .foregroundStyle(.blue)
                .symbolEffect(.variableColor.iterative, isActive: nfcManager.isScanning)

            Button {
                nfcManager.startReading()
            } label: {
                Label("Scan NFC Tag", systemImage: "sensor.tag.radiowaves.forward.fill")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(.blue.gradient, in: RoundedRectangle(cornerRadius: 14))
                    .foregroundStyle(.white)
                    .font(.headline)
            }
            .disabled(!nfcManager.isNFCAvailable)
            .padding(.horizontal)

            // Results
            if !nfcManager.scannedRecords.isEmpty {
                List(nfcManager.scannedRecords) { record in
                    VStack(alignment: .leading, spacing: 4) {
                        Text(record.type)
                            .font(.caption)
                            .foregroundStyle(.secondary)
                            .padding(.horizontal, 8)
                            .padding(.vertical, 2)
                            .background(.blue.opacity(0.1), in: Capsule())

                        Text(record.payload)
                            .font(.body)
                            .textSelection(.enabled)
                    }
                    .padding(.vertical, 4)
                }
                .listStyle(.insetGrouped)
            }
        }
    }

    var writeTextView: some View {
        VStack(spacing: 20) {
            TextField("Enter text to write", text: $writeText, axis: .vertical)
                .textFieldStyle(.roundedBorder)
                .lineLimit(3...6)
                .padding(.horizontal)

            Button {
                nfcManager.startWriting(text: writeText)
            } label: {
                Label("Write to Tag", systemImage: "square.and.pencil")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(.green.gradient, in: RoundedRectangle(cornerRadius: 14))
                    .foregroundStyle(.white)
                    .font(.headline)
            }
            .disabled(writeText.isEmpty || !nfcManager.isNFCAvailable)
            .padding(.horizontal)
        }
    }

    var writeURLView: some View {
        VStack(spacing: 20) {
            TextField("https://example.com", text: $writeURL)
                .textFieldStyle(.roundedBorder)
                .keyboardType(.URL)
                .autocapitalization(.none)
                .padding(.horizontal)

            Button {
                if let url = URL(string: writeURL) {
                    nfcManager.startWritingURL(url)
                }
            } label: {
                Label("Write URL to Tag", systemImage: "link")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(.orange.gradient, in: RoundedRectangle(cornerRadius: 14))
                    .foregroundStyle(.white)
                    .font(.headline)
            }
            .disabled(URL(string: writeURL) == nil || !nfcManager.isNFCAvailable)
            .padding(.horizontal)
        }
    }
}