How to Build a React Native ID Card Scanner That Reads the MRZ
Identity documents are issued in many countries to prove a person’s identity. They usually have a card design around 8.6 cm x 5.4 cm in size with a three-line MRZ, like the ID cards in Germany and the Netherlands.
Dutch ID Card Specimen:

An ID card scanner in React Native needs four things: a camera preview, card edge detection, MRZ OCR, and a parser that turns the MRZ into fields. All four are part of dynamsoft-capture-vision-react-native v3.6.2000 — the camera is CameraEnhancer, edge detection is the document normalizer, MRZ OCR is the label recognizer, and the MRZ text is decoded by Dynamsoft Code Parser. The app in this tutorial captures the front and the back of the card, stores a straightened card-shaped image of each side, and fills in the card holder’s details from the MRZ.
What you’ll build: A React Native app (Android 5.0+ / iOS 13+) that captures both sides of an ID card, detects and deskews the card from the live camera feed, reads the MRZ on the back, and extracts Surname, GivenName, IDNumber, DateOfBirth and DateOfExpiry — then stores the card on the device so it can be reviewed and edited later. Everything runs on the device; nothing is uploaded.
Key Takeaways
- One package covers the pipeline.
dynamsoft-capture-vision-react-nativev3.6.2000 providesCameraViewandCameraEnhancerfor the preview,CaptureVisionRouterfor the processing workflow, the document normalizer for card edges, and the label recognizer plus Code Parser for the MRZ. - The MRZ recognition models are not part of the Capture Vision bundle.
com.dynamsoft:mrzscannerbundle:3.6.2000carriesassets/Models/MRZ*.data, and without that dependency the MRZ template runs witherrorCode=0and returns zero items for every frame. ReadPassportAndIdreads all three ICAO layouts — TD1 and TD2 ID cards plus TD3 passports — so one template name covers every document type.- No MRZ parsing library is required.
ParsedResultItem.parsedFieldsalready exposes named ICAO fields —primaryIdentifier,secondaryIdentifier,documentNumber,birthYear,expiryMonth, and so on. - The MRZ text lines come back as one item, not one item per line: the template concatenates them and keeps the line breaks inside
text, so alines.lengthcheck has to become a length check on the string. - Capture is gated twice:
MultiFrameResultCrossFiltercross-verifies the detected card corners across frames, and the MRZ handler only accepts a read that two consecutive frames agree on. setColourChannelUsageType(CCUT_FULL_CHANNEL)is what keeps the saved image in colour; with the default stream the frame handed togetOriginalImage()is greyscale.
Common Developer Questions
How do I read an ID card MRZ in React Native?
Start CaptureVisionRouter with the ReadPassportAndId template on a camera stream and read parsedResult.items[0].parsedFields in the result receiver. The router runs text-line localization, MRZ character recognition and Code Parser in one pass, so the result arrives as named fields instead of raw MRZ lines.
Why does the MRZ template return no results?
Because the MRZ models are missing from the app. dynamsoft-capture-vision-react-native depends on the Capture Vision bundle, which ships the OCR engine but not MRZCharRecognition.data, MRZTextLineRecognition.data or MRZLocalization.data. Add com.dynamsoft:mrzscannerbundle for the same version on Android — and the matching MRZ framework on iOS — so those files land in the app’s assets/Models folder. The failure is silent: the result has errorCode=0 and an empty items array.
Do I need react-native-vision-camera to scan documents in React Native?
No. CameraView renders the preview and CameraEnhancer owns the camera session, then CaptureVisionRouter.setInput() feeds those frames straight into the processing pipeline. That removes the frame processor, the worklets runtime and the separate cropping plugin from the dependency list.
Which template reads an ID card MRZ, and which one reads a passport?
ReadPassportAndId handles both: it runs the text-line specifications and parsers for TD1 (three lines of 30 characters), TD2 (two lines of 36 characters) and TD3 (two lines of 44 characters). ReadId is the card-only variant when passports are out of scope, and ReadPassport the passport-only one.
Why is the year in my MRZ date off by a century?
MRTD dates are printed as six digits — YYMMDD — with no century. A birth year above the current two-digit year belongs to the previous century, while an expiry year belongs to the nearest future window. The sample expands both cases in src/services/mrz.ts.
Can I get the card image and the MRZ text from the same frame?
Yes. getIntermediateResultManager().getOriginalImage(result.originalImageHashId) returns the frame the MRZ was read from. That frame is the full camera image, so the sample runs cvr.capture(image, PT_DETECT_AND_NORMALIZE_DOCUMENT) on it to get a card-shaped crop before saving — the same shape the front side comes back with.
Prerequisites
Before starting, make sure you have:
- Node.js 18+ and npm
- React Native CLI (not Expo) with React Native 0.79
- Android Studio with a physical device or emulator running Android 5.0 (API 21)+
- Xcode 15+ with CocoaPods for iOS 13+
- JDK 17
- A Dynamsoft Capture Vision trial or full license key
Step 1: Create the Project and Install the Dependencies
Create the project and install the SDK together with the UI dependencies:
npx @react-native-community/cli init ScanIDCard
cd ScanIDCard
npm install dynamsoft-capture-vision-react-native@3.6.2000 @react-native-async-storage/async-storage @react-navigation/native @react-navigation/native-stack react-native-safe-area-context react-native-screens
For iOS, install the native pods:
cd ios && pod install && cd ..
The Capture Vision bundle ships the engine, but the MRZ recognition models live in a separate artifact. Add it to android/app/build.gradle so its assets/Models/MRZ*.data files are merged into the APK:
dependencies {
implementation("com.facebook.react:react-android")
// Carries the MRZ recognition models and MRZ templates.
implementation("com.dynamsoft:mrzscannerbundle:3.6.2000")
}
That one line is the difference between a working MRZ reader and a silent no-op. Without it startCapturing("ReadPassportAndId") resolves successfully, every frame comes back with errorCode=0, and items is empty — no exception, no error message, just nothing. On iOS the matching MRZ framework has to be added the same way, so its Models resources are copied into the app bundle.
Each package has one job:
| Package | Role |
|---|---|
dynamsoft-capture-vision-react-native |
Camera preview, document detection, MRZ OCR and MRZ parsing |
com.dynamsoft:mrzscannerbundle (Android) |
MRZ models and MRZ templates |
@react-native-async-storage/async-storage |
Stores the scanned cards on the device |
@react-navigation/native + native-stack |
Home → Card → Camera navigation |
react-native-safe-area-context + react-native-screens |
Required by React Navigation |
The Capture Vision bundle already declares CAMERA and INTERNET in its own manifest, and Gradle merges them into the app at build time. The sample repeats CAMERA in android/app/src/main/AndroidManifest.xml so the requirement is visible without opening a dependency. iOS still requires a usage description in ios/ScanIDCard/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Open the camera to scan ID cards.</string>
Step 2: Initialize the License and Define the Navigation Contract
App.tsx initializes the license once, blocks the UI while it runs, and only then mounts the navigator:
function App(): React.JSX.Element {
const [licenseState, setLicenseState] = useState<'pending' | 'ready' | 'failed'>(
'pending',
);
const [licenseError, setLicenseError] = useState('');
const initLicense = useCallback(async () => {
setLicenseState('pending');
try {
await LicenseManager.initLicense(LICENSE_KEY);
setLicenseState('ready');
} catch (e: any) {
setLicenseError(e?.message ?? String(e));
setLicenseState('failed');
}
}, []);
useEffect(() => {
initLicense();
}, [initLicense]);
if (licenseState === 'pending') {
return <Splash />;
}
if (licenseState === 'failed') {
return <LicenseError message={licenseError} onRetry={initLicense} />;
}
// ... NavigationContainer + Stack.Navigator
}
Gating the navigator on the license state keeps every screen from starting a capturing session that is doomed to fail. Splash and LicenseError are two small local components — a spinner and an error card with a retry button.
The navigator has three screens, and the camera screen only needs to know which side it is capturing:
export type RootStackParamList = {
Home: undefined;
Card: {cardKey?: string};
Camera: {side: CardSide};
};
The captured image does not travel through navigation params. Replacing the params of the card screen makes React Navigation rebuild that screen, and a rebuilt screen starts from empty state — which silently drops the image of the other side. The sample keeps the card being edited in a module-level draft instead:
export interface CardDraftState {
cardKey: string;
frontImage: string;
backImage: string;
info: CardInfo;
}
let draft = empty();
export const CardDraft = {
read(): CardDraftState {
return draft;
},
patch(patch: Partial<CardDraftState>) {
draft = {...draft, ...patch};
},
applyCapture(side: CardSide, base64: string, info?: CardInfo) {
draft =
side === 'front'
? {...draft, frontImage: base64, info: info ?? draft.info}
: {...draft, backImage: base64, info: info ?? draft.info};
},
reset() {
draft = empty();
},
};
The camera screen writes a capture into it and pops back; the card screen reads it when it regains focus.
Step 3: Capture the Card Front with Live Document Detection
The camera screen is driven by two singletons — CameraEnhancer for the preview and CaptureVisionRouter for the processing workflow. Because they are singletons, setInput() and addFilter() must run exactly once for the whole app lifetime:
// CameraEnhancer and CaptureVisionRouter are singletons, so the input and the
// filters may only be wired once for the whole app lifetime.
let inputReady = false;
export function CameraScreen({navigation, route}: StackScreenProps<'Camera'>) {
const side = route.params.side;
const isMRZ = side === 'back';
const camera = useRef(CameraEnhancer.getInstance()).current;
const cvr = useRef(CaptureVisionRouter.getInstance()).current;
// ...
}
The preview is bound and opened while the screen is focused. The preview handle has to be set before open() so that the first frame already has somewhere to render, and the stream is configured for OCR-quality colour frames:
useFocusEffect(
useCallback(() => {
if (cameraViewRef.current) {
camera.setCameraView(cameraViewRef.current);
}
camera.setResolution(EnumResolution.Resolution1080P);
// Keep the full colour frame: `getOriginalImage()` returns whatever the
// camera feeds the pipeline, and a Y-channel-only stream saves a grey JPEG.
camera.setColourChannelUsageType(EnumColourChannelUsageType.CCUT_FULL_CHANNEL);
camera.open();
return () => camera.close();
}, [camera]),
);
The setup effect wires the router, registers one result receiver for both modes, and starts the template that matches the side being scanned:
useEffect(() => {
let cancelled = false;
const start = async () => {
await CameraEnhancer.requestCameraPermission();
// A previous screen may have left a session running on the singleton.
await cvr.stopCapturing().catch(() => {});
if (cancelled) {
return;
}
if (!inputReady) {
cvr.setInput(camera);
const filter = new MultiFrameResultCrossFilter();
// Only report a deskewed card once several frames agree on its corners.
filter.enableResultCrossVerification(
EnumCapturedResultItemType.CRIT_DESKEWED_IMAGE,
true,
);
cvr.addFilter(filter);
inputReady = true;
}
receiverRef.current = cvr.addResultReceiver({
onCapturedResultReceived: handleResult,
});
try {
await cvr.startCapturing(
isMRZ
? 'ReadPassportAndId'
: EnumPresetTemplate.PT_DETECT_AND_NORMALIZE_DOCUMENT,
);
} catch (e: any) {
if (!cancelled) {
setError(e?.message ?? 'Failed to start the scanner.');
}
}
};
start();
return () => {
cancelled = true;
if (receiverRef.current) {
cvr.removeResultReceiver(receiverRef.current);
receiverRef.current = null;
}
cvr.stopCapturing().catch(() => {});
camera.setCameraView(null);
};
}, []);
PT_DETECT_AND_NORMALIZE_DOCUMENT is the preset template used by the React Native document scanner. It detects the card boundary, returns a DetectedQuadResultItem, and produces a perspective-corrected DeskewedImageResultItem — which is exactly what a card image should be.
The front half of the result handler waits for a verified detection, unless the user pressed the shutter:
const deskewed = result.processingDocumentResult?.deskewedImageResultItems?.[0];
if (!deskewed?.imageData) {
return;
}
const stable =
deskewed.crossVerificationStatus === EnumCrossVerificationStatus.CVS_PASSED;
if (!stable && !shutterRef.current) {
return;
}
shutterRef.current = false;
capturedRef.current = true;
setPending({image: deskewed.imageData});
Cross-verification is what makes automatic capture usable. Without it the app fires on the first frame that happens to look like a card, which is often a partial or blurred detection. With enableResultCrossVerification(CRIT_DESKEWED_IMAGE, true), the item is only marked CVS_PASSED after the same rectangle repeats across frames, so the shutter button becomes an override rather than the normal path.
The overlay is plain React Native views — four dimmed rectangles around a card-shaped window, plus four corner brackets:
const frameWidth = Math.min(width - 56, 340);
const frameHeight = frameWidth / CARD_RATIO;
const frameLeft = Math.round((width - frameWidth) / 2);
const frameTop = Math.round(height * 0.24);
<View style={[styles.mask, {top: 0, left: 0, right: 0, height: frameTop}]} />
<View style={[styles.mask, {top: frameTop + frameHeight, left: 0, right: 0, bottom: 0}]} />
<View style={[styles.mask, {top: frameTop, height: frameHeight, left: 0, width: frameLeft}]} />
<View style={[styles.mask, {top: frameTop, height: frameHeight, right: 0, width: frameLeft}]} />

Step 4: Read the MRZ on the Card Back
The MRZ branch of the same handler is where the OCR happens. The ReadPassportAndId template returns text lines and a parsed result for the same frame, so the handler can require both before it accepts a read:
if (isMRZ) {
const lines = result.recognizedTextLinesResult?.items ?? [];
const parsed = result.parsedResult?.items?.[0];
if (!parsed || lines.length === 0) {
return;
}
// The MRZ lines are concatenated into a single text-line item, with the
// original line breaks preserved inside the text.
const text = lines.map(line => line.text).join('\n').trim();
if (text.length < 30) {
return;
}
if (text !== mrzTextRef.current) {
// First sighting of this read — wait for a frame that confirms it.
mrzTextRef.current = text;
return;
}
const image = cvr
.getIntermediateResultManager()
.getOriginalImage(result.originalImageHashId);
if (!image || image.width === 0) {
return;
}
capturedRef.current = true;
setPending({image, info: cardInfoFromMRZ(parsed)});
return;
}
Three details are worth pointing out:
lines.length === 0only rejects an empty result. The template declaresConcatResults: 1, so a TD1 card’s three MRZ lines arrive as oneTextLineResultItemwhosetextalready contains the line breaks. Counting items does not tell you whether a full MRZ was read — a length check on the text does.- The text comparison rejects flicker. The first frame that produces a full MRZ is stored and discarded; only a second frame with identical text triggers the capture. That is cheaper than a cross-verification filter for this result type.
getOriginalImage()returns the frame that produced the result. It is the full camera image in colour, not a card crop — the ROI for this template runs the label recognizer only, so there is no document quad to crop with.
Step 5: Map the Parsed MRZ onto Card Fields
Dynamsoft Code Parser resolves the ICAO field names, so the mapping layer is a lookup table rather than a parser. It lives in src/services/mrz.ts:
import type {ParsedResultItem} from 'dynamsoft-capture-vision-react-native';
import type {CardInfo} from '../models/ScannedIDCard';
function readField(item: ParsedResultItem, name: string): string {
const value = item.parsedFields?.[name]?.value ?? '';
return value.replace(/[\r\n]/g, '').replace(/<+$/, '').trim();
}
const CURRENT_YEAR_IN_CENTURY = new Date().getFullYear() % 100;
function expandYear(twoDigitYear: string, isExpiry: boolean): string {
const value = Number(twoDigitYear);
if (!twoDigitYear || Number.isNaN(value)) {
return '';
}
if (!isExpiry) {
return value > CURRENT_YEAR_IN_CENTURY
? `19${twoDigitYear}`
: `20${twoDigitYear}`;
}
return value >= CURRENT_YEAR_IN_CENTURY &&
value <= CURRENT_YEAR_IN_CENTURY + 20
? `20${twoDigitYear}`
: `19${twoDigitYear}`;
}
function readDate(item: ParsedResultItem, prefix: 'birth' | 'expiry'): string {
const isExpiry = prefix === 'expiry';
const year = expandYear(readField(item, `${prefix}Year`), isExpiry);
const month = readField(item, `${prefix}Month`).padStart(2, '0');
const day = readField(item, `${prefix}Day`).padStart(2, '0');
if (!year || !month || !day) {
return '';
}
return `${year}-${month}-${day}`;
}
export function cardInfoFromMRZ(item: ParsedResultItem): CardInfo {
const documentNumber =
readField(item, 'documentNumber') || readField(item, 'passportNumber');
return {
Surname: readField(item, 'primaryIdentifier'),
GivenName: readField(item, 'secondaryIdentifier'),
IDNumber: documentNumber,
DateOfBirth: readDate(item, 'birth'),
DateOfExpiry: readDate(item, 'expiry'),
};
}
The field names come from the MRTD specification and are the ones the parser emits:
| Field in the card | parsedFields key |
Notes |
|---|---|---|
| Surname | primaryIdentifier |
Padded with < fillers, trimmed here |
| Given names | secondaryIdentifier |
May contain several given names |
| Document number | documentNumber / passportNumber |
documentNumber on ID cards, passportNumber on passports |
| Date of birth | birthYear + birthMonth + birthDay |
Two-digit year, century inferred |
| Date of expiry | expiryYear + expiryMonth + expiryDay |
Two-digit year, century inferred |
Reading a Romanian identity card confirms the shapes: the parser returns birthYear="86", birthMonth="10", birthDay="16" and expiryYear="29", which the helper turns into 1986-10-16 and 2029-10-16. The same result also exposes the packed originals as dateOfBirth="861016" and dateOfExpiry="291016", plus nationality, sex, issuingState and every check digit (checkDigitForDocumentNumber, compositeCheckDigit, …) with a validationStatus you can use to reject a bad read.
Two cleanup rules matter. MRZ fields are padded with < characters up to the field width, and a long name can wrap onto the next line, so the value is stripped of line breaks and trailing fillers before it reaches the UI.
Step 6: Normalize the Back Frame and Save the Card
Both sides should look the same in the card record: a card-shaped crop, in colour. The front already is one, because it comes out of the document template deskewed. The back is the raw frame, so the camera screen runs the document normalizer on it before handing it over:
useEffect(() => {
if (!pending) {
return;
}
setProcessing(true);
let cancelled = false;
(async () => {
await cvr.stopCapturing().catch(() => {});
// The front already comes out of the document template deskewed; the back
// is the raw frame the MRZ was read from, so normalize it here. When no
// card boundary is found the raw frame is kept.
let image = pending.image;
if (side === 'back') {
try {
const normalized = cvr.capture(
image,
EnumPresetTemplate.PT_DETECT_AND_NORMALIZE_DOCUMENT,
);
const deskewed =
normalized?.processingDocumentResult?.deskewedImageResultItems?.[0]
?.imageData;
if (deskewed) {
image = deskewed;
}
} catch {
// Keep the raw frame; the capture is still usable.
}
}
const base64 = imageDataToBase64(image) ?? '';
if (cancelled) {
return;
}
CardDraft.applyCapture(side, base64, pending.info);
navigation.goBack();
})();
return () => {
cancelled = true;
};
}, [pending]);
cvr.capture() is the still-image counterpart of startCapturing(): it runs a template over an ImageData and returns a CapturedResult. The stream has already been stopped by the first line of the effect, so the two never compete for the pipeline.
The card screen reads the draft on focus and renders both images with a fixed card aspect ratio:
useFocusEffect(
useCallback(() => {
refresh(); // setDraft({...CardDraft.read()})
}, [refresh]),
);
That effect is also what makes the flow robust: the screen can be rebuilt while the camera is on top, and the draft still holds the other side’s image.

Saving validates that no field was left empty, writes the record to AsyncStorage and resets the draft:
const saveCard = async () => {
const {info, frontImage, backImage, cardKey} = CardDraft.read();
const missing = CARD_INFO_FIELDS.filter(field => !info[field].trim());
if (missing.length > 0) {
Alert.alert('Card info not complete', `Missing: ${missing.join(', ')}`);
return;
}
const timestamp = cardKey ? Number(cardKey) : Date.now();
CardDraft.patch({cardKey: String(timestamp)});
await IDCardManager.save({timestamp, frontImage, backImage, info});
CardDraft.reset();
Alert.alert('Saved', 'The card is stored on this device.', [
{text: 'OK', onPress: () => navigation.popToTop()},
]);
};
IDCardManager is a thin wrapper over AsyncStorage that namespaces its keys, so getAllKeys() can tell card records apart from anything else the app stores:
const KEY_PREFIX = 'idcard:';
export class IDCardManager {
static async save(card: ScannedIDCard): Promise<void> {
await AsyncStorage.setItem(KEY_PREFIX + card.timestamp, JSON.stringify(card));
}
static async remove(timestamp: number): Promise<void> {
await AsyncStorage.removeItem(KEY_PREFIX + timestamp);
}
static async listKeys(): Promise<string[]> {
const keys = await AsyncStorage.getAllKeys();
return keys
.filter(key => key.startsWith(KEY_PREFIX))
.map(key => key.slice(KEY_PREFIX.length))
.sort((a, b) => Number(b) - Number(a));
}
static async load(key: string): Promise<ScannedIDCard | null> {
const json = await AsyncStorage.getItem(KEY_PREFIX + key);
return json ? (JSON.parse(json) as ScannedIDCard) : null;
}
}
The home screen lists the stored cards, newest first, and reloads on every focus so a newly saved card appears immediately. Each row loads its own record by key instead of receiving it as a prop, which keeps the full set of base64 images out of memory:

Tapping a row opens the card for editing, and a long press offers to delete the record.
Common Issues & Edge Cases
- The MRZ template returns zero items. The MRZ models are missing from the app — add
com.dynamsoft:mrzscannerbundle:3.6.2000(or the matching iOS framework) soassets/Models/MRZ*.datais packaged. Nothing in the result tells you this:errorCodeis0anditemsis empty. - The saved image is grey, not colour. The camera stream defaults to a luminance-only channel for processing. Call
camera.setColourChannelUsageType(EnumColourChannelUsageType.CCUT_FULL_CHANNEL)beforecamera.open()andgetOriginalImage()returns a colour frame. - The other side’s image disappears after a capture. Passing the capture through navigation params replaces the card screen’s params, and React Navigation rebuilds that screen — the rebuilt screen has no state. Keep the in-progress card in a module-level draft and pop back with
goBack(). - The trial license needs a network connection at startup.
LicenseManager.initLicense()validates the trial key against Dynamsoft’s license server, so the app shows the license error screen when the device is offline. Replace the key with a full license for offline deployments. - Bind the preview before opening the camera.
camera.setCameraView()has to run beforecamera.open(), which is why both calls sit in the same focus effect. Splitting them across two effects can leave the session without a bound preview handle. startCapturing()rejects when a session is already running.CameraEnhancerandCaptureVisionRouterare singletons, so a session left running by a previous screen blocks the next one. Every screen in this sample callsstopCapturing()before it starts its own template, and the camera screen stops capturing as soon as a frame is captured.setInput()andaddFilter()accumulate. They are additive on the singleton instance, so wiring them inside a component effect without a module-level guard stacks duplicate filters on every mount.- Cross-verification never passes for a worn card. A card with curled corners or a strong reflection may not produce a stable quad. The shutter button bypasses the check and captures the current deskewed frame immediately.
- Base64 images grow the AsyncStorage record. Both sides are cropped to the card, which keeps a record in the low hundreds of kilobytes. An app that keeps hundreds of cards should write the JPEGs to disk with
ImageIO.saveToFile()and store the file paths instead. - Release only the images you created. Calling
release()on anImageDatathat came from a result item or fromgetOriginalImage()can free memory the SDK still owns. Derived images — the output ofImageProcessor.convertToGray()orconvertToBinaryLocal(), for example — are yours, and releasing them keeps repeated scans from growing native memory.
Conclusion
The scanner in this tutorial is a complete ID card workflow in React Native: a live camera preview, card detection and deskew for the front, MRZ reading and parsing for the back, a matching card-shaped crop for both sides, and a local card library that stays editable. Camera access, document detection, MRZ OCR and MRZ parsing come from dynamsoft-capture-vision-react-native v3.6.2000, with the MRZ models supplied by the same-version MRZ Scanner bundle. For barcode reading in the same app, CaptureVisionRouter can run ReadBarcodes_Default on the same CameraEnhancer input — see the Dynamsoft Capture Vision documentation for the template reference.
Source Code
Get the complete sample project source code on GitHub
Disclaimer:
The wrappers and sample code on Dynamsoft Codepool are community editions, shared as-is and not fully tested. Dynamsoft is happy to provide technical support for users exploring these solutions but makes no guarantees.