Emirates ID & Phone Validation in Next.js / React
A step-by-step guide for integrating KhaleejiAPI's regional validation endpoints into Next.js App Router forms, including input masking, Zod schemas, secure server-side API calls, and bilingual (English / Arabic) error messages.
What you'll build
- Input masking — auto-formats Emirates ID to
784-YYYY-XXXXXXX-Xand UAE/KSA phone numbers to+971 XX XXX XXXXas the user types. - Zod schemas that mirror the KhaleejiAPI validation rules so client-side errors match server-side errors exactly.
- Next.js API route handlers that securely proxy validation calls using your
KHALEEJI_API_KEYenvironment variable — the key never reaches the browser. - Bilingual error messages (English + Arabic) mapped from KhaleejiAPI error codes.
Prerequisites
- Next.js 16+ (App Router)
react-hook-form+@hookform/resolvers+zod- A KhaleejiAPI key — grab one free from the dashboard
Add your key to .env.local:
# .env.localKHALEEJI_API_KEY=kapi_your_secret_key_here1Client-side Zod schemas
Define schemas that validate the raw user input before making an API call. The schemas strip formatting characters (dashes, spaces) so the same values are safe to pass to the API.
import { z } from "zod" // ── Emirates ID ───────────────────────────────────────────────────────────────// Format: 784-YYYY-XXXXXXX-X (784 = ISO 3166-1 numeric for UAE)// Raw digits: 15 characters totalconst emiratesIdSchema = z .string() .min(1, { message: "Emirates ID is required" }) .transform((v) => v.replace(/[^0-9]/g, "")) // strip formatting dashes .refine((v) => v.length === 15, { message: "Emirates ID must be 15 digits", }) .refine((v) => v.startsWith("784"), { message: "Emirates ID must start with 784 (UAE country code)", }) // ── Phone number ──────────────────────────────────────────────────────────────// Accepts UAE (+971) and KSA (+966) numbers in international formatconst phoneSchema = z .string() .min(1, { message: "Phone number is required" }) .transform((v) => v.replace(/\s/g, "")) // strip whitespace .refine( (v) => /^\+971[0-9]{8,9}$/.test(v) || /^\+966[0-9]{8,9}$/.test(v), { message: "Enter a valid UAE (+971) or KSA (+966) phone number", }, ) // ── Combined checkout / KYC form ──────────────────────────────────────────────export const kycFormSchema = z.object({ emiratesId: emiratesIdSchema, phone: phoneSchema,})transform step normalizes input before refine runs, so the validated output is always a clean digit string — ready to pass directly to the API.2Input masking utilities
These pure functions format the raw value as the user types and expose a React hook that handles cursor preservation.
"use client"import { useRef } from "react" // ── Emirates ID mask: 784-YYYY-XXXXXXX-X ─────────────────────────────────────export function formatEmiratesId(raw: string): string { const digits = raw.replace(/[^0-9]/g, "").slice(0, 15) // Insert dashes at positions 3, 7, 14 if (digits.length <= 3) return digits if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}` if (digits.length <= 14) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}` return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 14)}-${digits[14]}`} // ── Phone mask: +971 XX XXX XXXX ─────────────────────────────────────────────export function formatPhone(raw: string): string { const stripped = raw.replace(/[^0-9+]/g, "") // Preserve leading + and prefix digits if (!stripped.startsWith("+")) return raw const code = stripped.startsWith("+971") ? "+971" : stripped.startsWith("+966") ? "+966" : null if (!code) return stripped.slice(0, 13) const local = stripped.slice(code.length).slice(0, 9) if (local.length === 0) return code if (local.length <= 2) return `${code} ${local}` if (local.length <= 5) return `${code} ${local.slice(0, 2)} ${local.slice(2)}` return `${code} ${local.slice(0, 2)} ${local.slice(2, 5)} ${local.slice(5)}`} // ── Masked input hook ─────────────────────────────────────────────────────────export function useMaskedInput( formatter: (v: string) => string, onChange: (formatted: string) => void,) { const ref = useRef<HTMLInputElement>(null) function handleChange(e: React.ChangeEvent<HTMLInputElement>) { const formatted = formatter(e.target.value) onChange(formatted) // preserve cursor position after re-formatting const pos = e.target.selectionStart ?? formatted.length requestAnimationFrame(() => { ref.current?.setSelectionRange(pos, pos) }) } return { ref, onChange: handleChange }}3Next.js API route handlers
Create two thin proxy routes that forward validation requests to KhaleejiAPI. The KHALEEJI_API_KEY is read from the server environment and is never sent to the browser.
Emirates ID — /api/validate/emirates-id
// app/api/validate/emirates-id/route.tsimport { NextRequest, NextResponse } from "next/server"import { z } from "zod" const schema = z.object({ id: z.string().min(1) }) export async function GET(req: NextRequest) { const id = req.nextUrl.searchParams.get("id") ?? "" const parsed = schema.safeParse({ id }) if (!parsed.success) { return NextResponse.json({ error: "id parameter is required" }, { status: 400 }) } const res = await fetch( `https://khaleejiapi.dev/api/v1/emirates-id/validate?id=${encodeURIComponent(parsed.data.id)}`, { headers: { Authorization: `****** }, // Next.js route-handler caches GET responses automatically; // set revalidate if you want time-based cache refresh next: { revalidate: 0 }, }, ) const body = await res.json() // Forward HTTP status verbatim so the client can distinguish 4xx / 5xx return NextResponse.json(body, { status: res.status })}Phone — /api/validate/phone
// app/api/validate/phone/route.tsimport { NextRequest, NextResponse } from "next/server"import { z } from "zod" const schema = z.object({ phone: z.string().min(1) }) export async function GET(req: NextRequest) { const phone = req.nextUrl.searchParams.get("phone") ?? "" const parsed = schema.safeParse({ phone }) if (!parsed.success) { return NextResponse.json({ error: "phone parameter is required" }, { status: 400 }) } const res = await fetch( `https://khaleejiapi.dev/api/v1/phone/validate?phone=${encodeURIComponent(parsed.data.phone)}`, { headers: { Authorization: `****** }, next: { revalidate: 0 }, }, ) const body = await res.json() return NextResponse.json(body, { status: res.status })}process.env.KHALEEJI_API_KEY in client components or pass the key through NEXT_PUBLIC_* variables. The proxy pattern above keeps it server-only.4Browser-safe API client
A thin wrapper that calls your own proxy routes and normalizes the response into a consistent shape.
// lib/validation-client.ts (browser-safe — never imports env vars)export interface ValidationResult { valid: boolean formatted?: string // eslint-disable-next-line @typescript-eslint/no-explicit-any details?: Record<string, any> error?: { code: string; message: string }} export async function validateEmiratesId(id: string): Promise<ValidationResult> { const res = await fetch( `/api/validate/emirates-id?id=${encodeURIComponent(id)}`, ) const body = await res.json() as { data?: { valid?: boolean; formatted?: string }; error?: { code: string; message: string } } if (!res.ok || body.error) { return { valid: false, error: body.error } } return { valid: body.data?.valid ?? false, formatted: body.data?.formatted, details: body.data as Record<string, unknown>, }} export async function validatePhone(phone: string): Promise<ValidationResult> { const res = await fetch( `/api/validate/phone?phone=${encodeURIComponent(phone)}`, ) const body = await res.json() as { data?: { valid?: boolean; formatted?: string }; error?: { code: string; message: string } } if (!res.ok || body.error) { return { valid: false, error: body.error } } return { valid: body.data?.valid ?? false, formatted: body.data?.formatted, details: body.data as Record<string, unknown>, }}5Bilingual error messages
Map KhaleejiAPI error codes to user-friendly messages in both English and Arabic. Pass the user's locale to the helper to get the right string.
// lib/validation-errors.tstype Locale = "en" | "ar" const messages: Record<string, Record<Locale, string>> = { // Emirates ID EMIRATES_ID_INVALID: { en: "This Emirates ID number is not valid. Please check and try again.", ar: "رقم الهوية الإماراتية غير صالح. يُرجى التحقق والمحاولة مرة أخرى.", }, EMIRATES_ID_FORMAT: { en: "Emirates ID must be in the format 784-YYYY-XXXXXXX-X.", ar: "يجب أن يكون رقم الهوية بالتنسيق 784-YYYY-XXXXXXX-X.", }, // Phone PHONE_INVALID: { en: "This phone number is not valid for UAE or KSA. Use +971 or +966 prefix.", ar: "رقم الهاتف غير صالح للإمارات أو المملكة. استخدم البادئة +971 أو +966.", }, PHONE_NOT_ACTIVE: { en: "This phone number does not appear to be active.", ar: "يبدو أن رقم الهاتف هذا غير نشط.", }, // Generic NETWORK_ERROR: { en: "A network error occurred. Please try again.", ar: "حدث خطأ في الشبكة. يُرجى المحاولة مرة أخرى.", }, RATE_LIMIT_EXCEEDED: { en: "Too many requests. Please wait a moment and try again.", ar: "طلبات كثيرة جداً. يُرجى الانتظار لحظة ثم المحاولة مرة أخرى.", }, UNKNOWN: { en: "An unexpected error occurred. Please contact support if this persists.", ar: "حدث خطأ غير متوقع. يُرجى التواصل مع الدعم إذا استمر هذا.", },} export function getErrorMessage(code: string, locale: Locale = "en"): string { return messages[code]?.[locale] ?? messages.UNKNOWN![locale]!} // Map KhaleejiAPI error codes → localised message keysexport function mapApiErrorCode(apiCode: string): string { const mapping: Record<string, string> = { INVALID_EMIRATES_ID: "EMIRATES_ID_INVALID", INVALID_FORMAT: "EMIRATES_ID_FORMAT", INVALID_PHONE: "PHONE_INVALID", INACTIVE_NUMBER: "PHONE_NOT_ACTIVE", RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED", } return mapping[apiCode] ?? "UNKNOWN"}6Putting it all together — KYC form component
Combine the masks, schemas, proxy client, and error messages into a complete react-hook-form component with RTL support.
"use client"import { useState } from "react"import { useForm } from "react-hook-form"import { zodResolver } from "@hookform/resolvers/zod"import { z } from "zod" import { kycFormSchema } from "./schemas"import { formatEmiratesId, formatPhone, useMaskedInput } from "./masks"import { validateEmiratesId, validatePhone } from "./api-client"import { getErrorMessage } from "./error-messages" type KycFormValues = z.infer<typeof kycFormSchema> export function KycForm({ locale = "en" }: { locale?: "en" | "ar" }) { const [serverErrors, setServerErrors] = useState<Record<string, string>>({}) const { register, handleSubmit, setValue, watch, formState: { errors, isSubmitting }, } = useForm<KycFormValues>({ resolver: zodResolver(kycFormSchema), }) const idMask = useMaskedInput(formatEmiratesId, (v) => setValue("emiratesId", v)) const phoneMask = useMaskedInput(formatPhone, (v) => setValue("phone", v)) async function onSubmit(values: KycFormValues) { setServerErrors({}) const [idResult, phoneResult] = await Promise.all([ validateEmiratesId(values.emiratesId), validatePhone(values.phone), ]) const errs: Record<string, string> = {} if (!idResult.valid) errs.emiratesId = getErrorMessage("EMIRATES_ID_INVALID", locale) if (!phoneResult.valid) errs.phone = getErrorMessage("PHONE_INVALID", locale) if (Object.keys(errs).length) { setServerErrors(errs) return } // ✅ both valid — proceed with your KYC flow console.log("Validated:", { id: idResult, phone: phoneResult }) } const isRtl = locale === "ar" return ( <form onSubmit={handleSubmit(onSubmit)} dir={isRtl ? "rtl" : "ltr"} className="space-y-5" > {/* Emirates ID */} <div> <label className="block text-sm font-medium mb-1"> {isRtl ? "رقم الهوية الإماراتية" : "Emirates ID"} </label> <input {...register("emiratesId")} ref={idMask.ref} onChange={idMask.onChange} placeholder="784-1990-1234567-1" inputMode="numeric" className="w-full rounded border px-3 py-2 font-mono text-sm" /> {(errors.emiratesId ?? serverErrors.emiratesId) && ( <p className="text-red-500 text-xs mt-1"> {errors.emiratesId?.message ?? serverErrors.emiratesId} </p> )} </div> {/* Phone */} <div> <label className="block text-sm font-medium mb-1"> {isRtl ? "رقم الهاتف" : "Phone Number"} </label> <input {...register("phone")} ref={phoneMask.ref} onChange={phoneMask.onChange} placeholder="+971 50 123 4567" inputMode="tel" type="tel" className="w-full rounded border px-3 py-2 text-sm" /> {(errors.phone ?? serverErrors.phone) && ( <p className="text-red-500 text-xs mt-1"> {errors.phone?.message ?? serverErrors.phone} </p> )} </div> <button type="submit" disabled={isSubmitting} className="rounded bg-accent px-4 py-2 text-sm font-medium text-white disabled:opacity-60" > {isSubmitting ? isRtl ? "جارٍ التحقق…" : "Validating…" : isRtl ? "تحقق" : "Validate"} </button> </form> )}Tips & best practices
- Debounce API calls. Trigger the proxy call on blur rather than on every keystroke to avoid unnecessary requests during typing.
- Cache validation results. Emirates IDs and phone numbers don't change mid-session. Cache a positive result in
useStateor a form-level ref so the user only pays the round-trip cost once. - Show formatted output. After a successful validation, display the
formattedfield returned by the API (e.g.784-1990-1234567-1) to confirm what was accepted. - Handle rate limits gracefully. Map
RATE_LIMIT_EXCEEDEDto the Arabic/English message above and disable the submit button for a few seconds instead of showing a raw error. - Locale from Accept-Language. Read the user's preferred language from
navigator.languageor your i18n provider and pass it togetErrorMessageautomatically. - RTL layout. Set
dir="rtl"on the form and use CSS logical properties (margin-inline-start,text-align: start) rather than left/right to support both directions without duplicated styles.
Related API references
- Emirates ID Validation API
- Phone Number Validation API
- Secure Browser Integration guide — proxy patterns for other endpoints
- Authentication & API keys