GuideNext.js · React · Zod

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-X and UAE/KSA phone numbers to +971 XX XXX XXXX as 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_KEY environment 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:

bash
# .env.local
KHALEEJI_API_KEY=kapi_your_secret_key_here

1Client-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.

ts
import { z } from "zod"
// Emirates ID
// Format: 784-YYYY-XXXXXXX-X (784 = ISO 3166-1 numeric for UAE)
// Raw digits: 15 characters total
const 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 format
const 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,
})

2Input masking utilities

These pure functions format the raw value as the user types and expose a React hook that handles cursor preservation.

ts
"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

ts
// app/api/validate/emirates-id/route.ts
import { 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

ts
// app/api/validate/phone/route.ts
import { 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 })
}

4Browser-safe API client

A thin wrapper that calls your own proxy routes and normalizes the response into a consistent shape.

ts
// 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.

ts
// lib/validation-errors.ts
type 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 keys
export 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.

tsx
"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 useState or a form-level ref so the user only pays the round-trip cost once.
  • Show formatted output. After a successful validation, display the formatted field returned by the API (e.g. 784-1990-1234567-1) to confirm what was accepted.
  • Handle rate limits gracefully. Map RATE_LIMIT_EXCEEDED to 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.language or your i18n provider and pass it to getErrorMessage automatically.
  • 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