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 throughrequireNativeModule. - The Dynamsoft MRZ Scanner template
ReadPassportAndIddoes 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 acanceledmessage 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
CapturedResultReceiverthat 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 withMatrix.setPolyToPoly. - An
IntermediateResultReceiverthat caches theScaledColourImageUnit,LocalizedTextLinesUnit,RecognizedTextLinesUnit,DetectedQuadsUnit, andDeskewedImageUnitneeded byIdentityProcessor.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:
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:
DynamsoftMRZScannerBundleis self-contained. AddingDynamsoftCore,DynamsoftLicense, orDynamsoftCaptureVisionBundleas separate pods registers duplicate Objective-C classes at launch and breaks the preview. Remove them and runpod installagain. - “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.initLicenseis asynchronous, and the engine blocks with aCountDownLatch(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:iosor a release build instead. - Cancel behavior: pressing the system back button or Cancel rejects the scan promise with message
canceled(result codeRESULT_CANCELEDon Android). The sample filters it with/cancel/iso 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 inConstants.licenseKeyof 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.