How to Build an Expo MRZ Document Scanner for Android and iOS

Verifying a passport or ID card on a phone usually means reading the Machine-Readable Zone (MRZ) at the bottom of the document. The MRZ encodes the holder’s name, document number, nationality, and dates in a fixed format that OCR alone cannot reliably parse — you need a recognizer trained on the MRZ syntax, plus a camera pipeline that can read the zone from a live video frame. The Dynamsoft MRZ Scanner SDK provides exactly that, and this tutorial shows how to call it from an Expo app.

Expo apps normally need third-party plugins for native features. This project does something different: the native bridge is implemented by hand inside the app as a local Expo module, so there is no third-party scanning plugin between your code and the Dynamsoft native SDKs on Android and iOS.

What you’ll build: An Expo SDK 57 app (expo-mrz-document-scanner) that opens a full-screen native camera scanner, reads the MRZ zone with the Dynamsoft MRZ Scanner template ReadPassportAndId, detects the document boundary, finds the portrait zone, and returns the parsed fields plus a perspective-corrected document image and a cropped portrait photo as JPEG data URLs. A system photo-picker path is included for scanning from an image file.

Demo Video: Expo MRZ Document Scanner in Action

Key Takeaways

  • You can integrate Dynamsoft native mobile SDKs into Expo without a third-party plugin by writing a local Expo module (Kotlin on Android, Swift on iOS) under the app’s modules/ folder and calling it from TypeScript through requireNativeModule.
  • The Dynamsoft MRZ Scanner template ReadPassportAndId does the whole job in one pipeline: MRZ recognition, document boundary detection, deskewing, and portrait-zone detection — the sample app only renders results and crops images.
  • The bridge resolves with parsed fields (documentType, name, sex, documentNumber, issuingState, nationality, dateOfBirth, dateOfExpiry, age) and rejects with a canceled message when the user backs out, which maps cleanly to a promise-based React Native API.
  • Camera and image-file inputs share the same template and result payload, so adding a photo-picker or file-URI scan path is just one more bridge method.

Common Developer Questions

How do I scan a passport MRZ in an Expo app without using a third-party plugin?

Create a local Expo module that wraps the Dynamsoft MRZ Scanner native SDK and launch it with a single promise-based call. The sample project ships a module named ExpoDynamsoftMrzScanner with initLicense(), startScan(), scanFromGallery(), and scanFile() methods implemented in Kotlin (ExpoDynamsoftMrzScannerModule.kt) and Swift (ExpoDynamsoftMrzScannerModule.swift).

What does the native scanner return after a successful scan?

A result object with a fields map containing the parsed MRZ data, and — when the document pipeline detects them — portraitBase64 and documentImageBase64 JPEG data URLs for the cropped portrait and the deskewed document image. On Android and iOS the same template name (ReadPassportAndId) and the same field names are used, so the TypeScript layer is platform-agnostic.

Can I run the sample in Expo Go?

No. The sample registers custom native code (activities, view controllers, and Dynamsoft SDK dependencies) that Expo Go does not contain, so it must run as a development or release build generated with npx expo prebuild / npx expo run:android / npx expo run:ios.

Which Dynamsoft packages does the app depend on?

The Android module adds com.dynamsoft:mrzscannerbundle:3.4.1300 in Gradle, and the iOS podspec adds DynamsoftMRZScannerBundle 3.4.1300 via CocoaPods. Each “bundle” is self-contained — it brings in Capture Vision, core, and license handling — so no extra Dynamsoft modules are needed.

Prerequisites

Before you start, make sure you have the following:

  • Node.js (LTS) and npm for the Expo toolchain
  • Android Studio (latest) with JDK 17 and a connected Android device or emulator
  • Xcode (latest, macOS only) with a connected iPhone and a signing team configured in Xcode → Settings → Accounts
  • A Dynamsoft license key — the sample embeds a time-limited trial key that requires a network connection on first use

Get a 30-day free trial license at dynamsoft.com/customer/license/trialLicense

How the Sample Project Is Organized

The complete app lives in the examples/expo-mrz-document-scanner folder of the sample repository. Its structure is:

expo-mrz-document-scanner/
├── app.json                          # App config: permissions, bundle IDs
├── App.tsx                           # Home + result screens (React Native UI)
├── assets/                           # App icons
└── modules/
    └── expo-dynamsoft-mrz-scanner/   # Local Expo module (the native bridge)
        ├── expo-module.config.json   # Registers the module for apple + android
        ├── src/ExpoDynamsoftMrzScannerModule.ts   # TypeScript surface
        ├── android/                  # Kotlin module + IdScanActivity scanner
        │   ├── build.gradle          # Dynamsoft mrzscannerbundle dependency
        │   └── src/main/AndroidManifest.xml
        └── ios/                      # Swift module + IdCameraScanViewController
            ├── ExpoDynamsoftMrzScanner.podspec
            ├── ExpoDynamsoftMrzScannerModule.swift
            └── IdCameraScanViewController.swift

The app imports the module’s TypeScript surface directly by relative path and Expo’s autolinking picks up the module folder at build time — nothing is published to npm.

Step 1: Scaffold the Expo App and the Local Module

Create the project with the blank TypeScript template and scaffold a local module with the Expo Module API:

npx create-expo-app@latest expo-mrz-document-scanner --template blank-typescript
cd expo-mrz-document-scanner
npx create-expo-module@latest --local modules/expo-dynamsoft-mrz-scanner

Depending on the tool version, the scaffold may create the module under modules/modules/ — move it up one level so the folder is modules/expo-dynamsoft-mrz-scanner, then delete the empty wrapper. Keep the platform list in expo-module.config.json limited to apple and android (the bridge has no web implementation) and make sure the class names in the config match the Kotlin and Swift module classes:

{
  "platforms": ["apple", "android"],
  "apple": {
    "modules": ["ExpoDynamsoftMrzScannerModule"]
  },
  "android": {
    "modules": ["expo.modules.dynamsoftmrzscanner.ExpoDynamsoftMrzScannerModule"]
  }
}

Step 2: Add the Camera and Photo Library Permissions

Declare the usage descriptions in app.json so prebuild generates the native permission entries. The Android module manifest also declares CAMERA, READ_MEDIA_IMAGES, and READ_EXTERNAL_STORAGE, which get merged into the app manifest automatically.

"ios": {
  "bundleIdentifier": "com.dynamsoft.expo.mrzscanner",
  "infoPlist": {
    "NSCameraUsageDescription": "Camera is used to scan documents and their Machine-Readable Zone (MRZ).",
    "NSPhotoLibraryUsageDescription": "The app lets you pick a document image from your photo library to scan it."
  }
}

Step 3: Define the TypeScript API and Wire Up the UI

The module surface in modules/expo-dynamsoft-mrz-scanner/src/ExpoDynamsoftMrzScannerModule.ts declares the four bridge methods. The scan methods resolve with the parsed fields and optional image data URLs:

export type IdScanResult = {
  fields: IdFieldMap;
  portraitBase64?: string;      // JPEG data URL of the cropped portrait
  documentImageBase64?: string; // JPEG data URL of the deskewed document
};

declare class ExpoDynamsoftMrzScannerModule extends NativeModule<{}> {
  initLicense(license?: string): Promise<InitLicenseResult>;
  startScan(): Promise<IdScanResult>;          // full-screen camera scanner
  scanFromGallery(): Promise<IdScanResult>;    // system photo picker
  scanFile(options: { uri: string }): Promise<IdScanResult>;
}

App.tsx keeps two screens: a home screen with a Camera / Image File toggle and a result screen. The scan flow is a plain async function — initialize the license, launch the chosen source, and treat a canceled rejection as a no-op:

const scan = async () => {
  setBusy(true);
  setError(null);
  try {
    const license = await ExpoDynamsoftMrzScanner.initLicense();
    if (!license.success) {
      setError(`License failed: ${license.message}`);
      return;
    }
    const outcome = mode === 'camera'
      ? await ExpoDynamsoftMrzScanner.startScan()
      : await ExpoDynamsoftMrzScanner.scanFromGallery();
    setResult(outcome);           // switches to the result screen
  } catch (e: any) {
    const message: string = e?.message ?? String(e);
    if (!/cancel/i.test(message)) {
      setError(message);          // user cancel: stay silent
    }
  } finally {
    setBusy(false);
  }
};

Step 4: Implement the Android Native Module

The Android side consists of an engine singleton, a full-screen scanner activity, and the Expo module that starts it.

Scanner engine

IdScannerEngine.kt owns one shared CaptureVisionRouter. It initializes the license with a blocking latch (20-second timeout), loads the MRZ templates that ship inside the mrzscannerbundle AAR, and parses still images:

@Synchronized
fun ensureTemplates() {
  if (templateReady) return
  try {
    router.initSettingsFromFile("mrzscanner-mobile-templates.json")
    templateReady = true
  } catch (e: CaptureVisionRouterException) {
    throw ScannerException("Failed to load MRZ templates: ${e.message}")
  }
}

fun template(): String = "ReadPassportAndId"

Scanner activity

IdScanActivity.kt is a portrait AppCompatActivity that binds a Dynamsoft CameraEnhancer (CameraX underneath) to the router, enables the full-channel colour mode and frame filter for better document capture, and registers two receivers:

  • A CapturedResultReceiver that fires whenever a document is parsed. It formats the fields, locates the portrait zone from the auxiliary region of the MRZ zone, and crops the portrait and the deskewed document with Matrix.setPolyToPoly.
  • An IntermediateResultReceiver that caches the ScaledColourImageUnit, LocalizedTextLinesUnit, RecognizedTextLinesUnit, DetectedQuadsUnit, and DeskewedImageUnit needed by IdentityProcessor.findPortraitZone().

The portrait zone is only accepted when the SDK reports a high-confidence auxiliary element named PortraitZone (confidence above 60) that sits fully inside the detected document quad:

// IdScanActivity.kt — inside onCapturedResultReceived
for (index in 0 until localized.auxiliaryRegionElementsCount) {
  val element = localized.getAuxiliaryRegionElement(index)
  if ("PortraitZone" == element.name && element.confidence > 60) {
    highConfidence = true
    break
  }
}
if (highConfidence && quads != null && quads.count > 0) {
  portraitZone = idProcessor.findPortraitZone(
    scaledColourImageUnit, localized, recognizedTextLinesUnit, quads, deskewedImageUnit)
}

The Confirm button serializes the result — fields, portraitBase64, documentImageBase64 — into a JSON payload and returns it to the caller with RESULT_OK.

Getting the activity result back to the module

An Expo module cannot register React Native activity-result listeners, so the module launches an invisible BridgeResultActivity. The bridge starts the real scanner with startActivityForResult, receives the outcome in its own onActivityResult, and resolves or rejects the promise the module parked in a static slot:

// ExpoDynamsoftMrzScannerModule.kt
AsyncFunction("startScan") { promise: Promise ->
  if (!ensureLicense(promise)) return@AsyncFunction
  val activity = appContext.currentActivity
  if (activity == null) {
    promise.reject("NO_ACTIVITY", "No foreground activity is available", null)
    return@AsyncFunction
  }
  BridgeResultActivity.pending =
    Pending(promise, BridgeResultActivity.KIND_SCAN, activity.applicationContext)
  activity.startActivity(
    Intent(activity, BridgeResultActivity::class.java)
      .putExtra(BridgeResultActivity.EXTRA_KIND, BridgeResultActivity.KIND_SCAN)
  )
}

Step 5: Implement the iOS Native Module

The iOS podspec declares the self-contained Dynamsoft framework. Do not add separate core or license pods — the bundle already embeds them, and duplicate Objective-C classes break the camera preview:

s.dependency 'ExpoModulesCore'
s.dependency 'DynamsoftMRZScannerBundle', '3.4.1300'

ExpoDynamsoftMrzScannerModule.swift presents IdCameraScanViewController from the current view controller on the main queue. The controller mirrors the Android flow: it finds the MRZ template (mrz-mobile.json) inside the Dynamsoft frameworks, sets up the drawing layers (document quad, MRZ lines, and a custom cyan portrait-zone layer), and runs IdentityProcessor.findPortraitZone when the pipeline reports the high-confidence PortraitZone auxiliary region:

let bundleCandidates = Bundle.allFrameworks + Bundle.allBundles
if let templatePath = bundleCandidates.lazy
  .compactMap({ $0.path(forResource: "mrz-mobile", ofType: "json") })
  .first {
  try? cvr.initSettingsFromFile(templatePath)
}

For the portrait crop the controller calls the SDK’s ImageProcessor().cropAndDeskewImage(original, quad:) on the original frame, then converts the resulting ImageData to UIImage with a pixel decoder (IdResultFormatter.image(from:)) that handles BGR888, ARGB8888, ABGR8888, NV12, and NV21 layouts — Dynamsoft documents channel order as stored “from high to low address”, so the in-memory byte sequence is the reverse of the format name. When no portrait zone is reported, a fallback crops the upper band of the deskewed document where passport photos normally sit.

Step 6: Understand the MRZ Result Payload

The formatter (IdResultFormatter.kt / IdResultFormatter.swift) turns the parsed MRZ item into the display map used by the result screen:

Field Meaning Example
documentType PASSPORT, ID, or VISA derived from the MRZ code type PASSPORT
name Last name + given names (LAST, FIRST) ERICSOHN, ANNA
sex Male / Female Female
documentNumber passport / document / ID number L898902C36
issuingState Issuing country UTOLAND
nationality Nationality UTOLAND
dateOfBirth YYYY-MM-DD 1974-08-12
dateOfExpiry YYYY-MM-DD 2028-03-15
age Computed from the birth date 52

Values that are absent on the document are formatted as an em dash (). The images are JPEG-compressed on the native side and passed back as data:image/jpeg;base64,... strings, which React Native renders directly with <Image source= />. The result screen shows the deskewed document in an ID-1 aspect-ratio frame and the portrait in a 3:4 frame:

Expo MRZ document scanner result screen

Step 7: Build and Run the App

Generate the native projects and run on a device:

npm install

# Android — builds and installs the debug app on the connected device/emulator
npx expo run:android

# iOS — builds and installs on a connected iPhone (Xcode signing required)
npx expo run:ios --device

The first Android build downloads the Dynamsoft AAR from Maven and may take several minutes. Self-contained release builds (no Metro bundler needed while testing) are produced with:

cd android && ./gradlew :app:assembleRelease   # APK in app/build/outputs/apk/release/
# or: npx expo run:ios --configuration Release --device

Point the camera at the MRZ zone of a passport (bottom of the bio-data page) or the back of an ID card. The preview draws the document quad, the MRZ text lines, and the cyan portrait zone; when a document is detected the Confirm button enables and the result screen opens.

Common Issues & Edge Cases

  • The app crashes or the camera preview stays black on iOS when extra Dynamsoft pods are added: DynamsoftMRZScannerBundle is self-contained. Adding DynamsoftCore, DynamsoftLicense, or DynamsoftCaptureVisionBundle as separate pods registers duplicate Objective-C classes at launch and breaks the preview. Remove them and run pod install again.
  • “No MRZ was found in the image” on the photo-picker path: the image must contain a complete, well-lit MRZ zone. Blurry or angled captures return this error; the message is surfaced through the promise rejection.
  • License initialization takes up to 20 seconds on Android: LicenseManager.initLicense is asynchronous, and the engine blocks with a CountDownLatch (20 s) so the scanner starts with a valid license. If the device is offline the handshake fails — replace the trial key with a full license for offline use.
  • Portrait and document images are missing on some documents: the portrait zone is only used when the auxiliary-region confidence is above 60 and the zone lies inside the detected document quad. TD1-style ID cards sometimes skip the auxiliary region — the iOS fallback crops the upper band of the deskewed document, while Android returns the fields without the portrait.
  • The app cannot be opened in Expo Go: custom native code requires a development or release build. Use npx expo run:android / npx expo run:ios or a release build instead.
  • Cancel behavior: pressing the system back button or Cancel rejects the scan promise with message canceled (result code RESULT_CANCELED on Android). The sample filters it with /cancel/i so the home screen stays clean instead of showing an error.
  • Trial license expires: the sample embeds a time-limited trial key. Replace it in License.kt (Android) and in Constants.licenseKey of the Swift module before shipping.

Source Code

Get the complete sample project source code on GitHub

The native bridge sources live in the modules/expo-dynamsoft-mrz-scanner folder of that project. For more on the underlying SDK, see the Dynamsoft Capture Vision documentation.