Saudi ID & VAT Validation with React and Next.js
Build an English/Arabic form for Saudi National IDs, Iqama, and VAT numbers. The examples keep your API key on the server and show accessible loading, success, invalid, and request-error states.
NEXT_PUBLIC_* variable. Store KHALEEJI_API_KEY in your server-only environment, protect your own route with your app's authentication and rate limits, and grant the validation:saudi-id and validation:vat scopes as needed. This form's browser checks are only for user experience; the API response is authoritative for its format checks.Use the live service-first endpoints
Saudi National ID and Iqama values are 10 digits (prefix 1 for National ID, 2 for Iqama). Saudi VAT numbers use 15 digits, starting and ending in 3. Send identifiers as text so formatting and leading zeroes are not lost.
Saudi National ID or Iqama — GET
/api/v1/saudi-id/validate?id=…Requires the validation:saudi-id scope. The response is wrapped in data; valid results include type, while invalid results include an errors array.
curl --get "https://khaleejiapi.dev/api/v1/saudi-id/validate" \ --data-urlencode "id=1012345607" \ -H "Authorization: Bearer $KHALEEJI_API_KEY"Saudi VAT — GET
/api/v1/vat/validate?tin=…&country=SARequires the validation:vat scope. This endpoint checks the number's format; it is not a live ZATCA registration lookup. Treat checkDigitValid as diagnostic information, not proof of registration.
curl --get "https://khaleejiapi.dev/api/v1/vat/validate" \ --data-urlencode "tin=310122393500073" \ --data-urlencode "country=SA" \ -H "Authorization: Bearer $KHALEEJI_API_KEY"See the Saudi ID API reference and VAT API reference for parameters and response details.
1. Add a server-only Next.js proxy
This route accepts a narrow input shape and selects only the two documented upstream URLs. Add your application's authentication, abuse protection, and request-size limits before exposing it to users.
// app/api/ksa/validate/route.tsimport { NextResponse } from "next/server" export async function POST(request: Request) { const body: unknown = await request.json().catch(() => null) if ( !body || typeof body !== "object" || !("kind" in body) || !("value" in body) || typeof body.kind !== "string" || typeof body.value !== "string" || !["saudi-id", "vat"].includes(body.kind) ) { return NextResponse.json( { error: { code: "INVALID_INPUT", message: "Invalid validation request" } }, { status: 400 }, ) } const apiKey = process.env.KHALEEJI_API_KEY if (!apiKey) { return NextResponse.json( { error: { code: "SERVER_CONFIG", message: "Validation is unavailable" } }, { status: 500 }, ) } const endpoint = new URL( body.kind === "saudi-id" ? "https://khaleejiapi.dev/api/v1/saudi-id/validate" : "https://khaleejiapi.dev/api/v1/vat/validate", ) endpoint.searchParams.set(body.kind === "saudi-id" ? "id" : "tin", body.value) if (body.kind === "vat") endpoint.searchParams.set("country", "SA") try { const upstream = await fetch(endpoint, { headers: { Authorization: "Bearer " + apiKey }, cache: "no-store", }) const result: unknown = await upstream.json() return NextResponse.json(result, { status: upstream.status }) } catch { return NextResponse.json( { error: { code: "UPSTREAM_ERROR", message: "Could not reach the validation service" } }, { status: 502 }, ) }}2. Use a bilingual React validation form
The text input preserves the identifier as entered. Inline status text is announced to assistive technology; the hint also appears as a native tooltip on the input.
"use client"import { useState, type FormEvent } from "react" type Locale = "en" | "ar"type Kind = "saudi-id" | "vat"type ResultBody = { data?: { valid?: boolean; reason?: string; errors?: string[] } error?: { code?: string; message?: string }} export function KsaValidationForm({ locale = "en" }: { locale?: Locale }) { const [kind, setKind] = useState<Kind>("saudi-id") const [value, setValue] = useState("") const [status, setStatus] = useState<"idle" | "loading" | "valid" | "invalid" | "error">("idle") const [message, setMessage] = useState("") const isArabic = locale === "ar" async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault() setStatus("loading") setMessage("") try { const response = await fetch("/api/ksa/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind, value }), }) const result = (await response.json()) as ResultBody if (!response.ok || result.error) { throw new Error(result.error?.message ?? "Validation request failed") } const valid = result.data?.valid === true setStatus(valid ? "valid" : "invalid") setMessage( valid ? isArabic ? "الرقم مطابق للتنسيق." : "The number matches the expected format." : isArabic ? "تعذر التحقق من التنسيق." : result.data?.reason ?? result.data?.errors?.join(" ") ?? "The number does not match the expected format.", ) } catch (error) { setStatus("error") setMessage( isArabic ? "تعذر الاتصال. حاول مرة أخرى." : error instanceof Error ? error.message : "Could not connect. Please try again.", ) } } const hint = kind === "saudi-id" ? isArabic ? "10 أرقام؛ الهوية تبدأ بـ 1 والإقامة بـ 2." : "10 digits; National IDs start with 1 and Iqama with 2." : isArabic ? "رقم ضريبة القيمة المضافة السعودي: 15 رقماً، يبدأ وينتهي بـ 3." : "Saudi VAT number: 15 digits, starting and ending with 3." return ( <form onSubmit={submit} dir={isArabic ? "rtl" : "ltr"} className="space-y-4"> <label className="block space-y-1"> <span>{isArabic ? "نوع التحقق" : "Validation type"}</span> <select value={kind} disabled={status === "loading"} onChange={(event) => { setKind(event.target.value as Kind) setValue("") setStatus("idle") setMessage("") }} className="block w-full rounded-md border bg-background px-3 py-2"> <option value="saudi-id">{isArabic ? "الهوية الوطنية / الإقامة" : "National ID / Iqama"}</option> <option value="vat">{isArabic ? "رقم ضريبة القيمة المضافة" : "VAT number"}</option> </select> </label> <label className="block space-y-1"> <span>{isArabic ? "الرقم" : "Number"}</span> <input value={value} disabled={status === "loading"} onChange={(event) => { setValue(event.target.value) setStatus("idle") setMessage("") }} type="text" inputMode="numeric" autoComplete="off" required aria-describedby="ksa-validation-hint" title={hint} className="block w-full rounded-md border bg-background px-3 py-2 font-mono" /> <span id="ksa-validation-hint" className="text-sm text-muted-foreground">{hint}</span> </label> <button type="submit" disabled={status === "loading"} className="rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-60" > {status === "loading" ? isArabic ? "جارٍ التحقق…" : "Checking…" : isArabic ? "تحقق" : "Validate"} </button> {message && ( <p role="status" aria-live="polite" title={message} className={status === "valid" ? "text-green-700" : status === "invalid" || status === "error" ? "text-destructive" : "text-muted-foreground"} > {message} </p> )} </form> )}Handling results
- Show a pending state while the request is in flight, and prevent duplicate submits.
- Use
data.validfor the valid/invalid result; display Saudi IDerrorsor VATreasonas contextual guidance. - For non-2xx responses, inspect
error.codeanderror.message. A401means the key is missing or invalid;403can indicate a missing scope;429means retry after the rate-limit window. - Network and upstream failures should offer a retry without treating the identifier as invalid. Do not log or retain full ID, Iqama, or VAT values unnecessarily.