Remix and React Router
Validate documents in Remix and React Router v7 framework mode with an action reading request.formData(), the React widget and a webhook resource route.
In this guide you add Spanish ID card (DNI) verification to React Router v7 in framework mode (Remix's successor). The same pieces work for Remix v2 with minimal changes, listed at the end.
- A resource route
app/routes/api.constaia.tswhoseactionreceives the file from the widget and calls Constaia with the JavaScript SDK. - A page with the widget through the React wrapper.
- A variant without the widget: a route with
<Form>and anaction. - A webhook resource route that verifies the signature with
request.text().
The key lives in a .server.ts module, which the bundler never includes in browser code.
Requirements
- React Router v7 in framework mode (or Remix v2) on Node ≥ 18.
- A
ck_test_...test key from the dashboard.
Install
npm i @constaia/sdk @constaia/widgetEnvironment variables
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...Load .env in your server (for example node --env-file=.env or dotenv). These variables are only read in .server.ts modules, loaders and actions.
1. Client and errors
import {
APITimeoutError,
AuthenticationError,
Constaia,
ConstaiaError,
InsufficientCreditsError,
InvalidRequestError,
PermissionError,
RateLimitError,
} from "@constaia/sdk";
let client: Constaia | undefined;
export function getConstaia(): Constaia {
client ??= new Constaia({ apiKey: process.env.CONSTAIA_API_KEY });
return client;
}
export interface HttpError {
status: number;
body: { error: { code: string; message: string } };
retryAfter?: number;
}
function fail(status: number, code: string, message: string, retryAfter?: number): HttpError {
return { status, body: { error: { code, message } }, retryAfter };
}
export function toHttpError(err: unknown): HttpError {
if (err instanceof ConstaiaError) console.error("constaia", err.status, err.code, err.requestId, err.message);
else console.error(err);
if (err instanceof InvalidRequestError) return fail(err.status ?? 400, err.code ?? "invalid_request", err.message);
if (err instanceof RateLimitError) {
return fail(429, "rate_limited", "Too many requests. Try again in a few seconds.", err.retryAfter);
}
if (err instanceof InsufficientCreditsError) {
return fail(503, "verification_unavailable", "Verification is not available right now.");
}
if (err instanceof AuthenticationError || err instanceof PermissionError) {
return fail(500, "server_misconfigured", "Server configuration error.");
}
if (err instanceof APITimeoutError) {
return fail(504, "timeout", "Verification took too long. Please try again.");
}
if (err instanceof ConstaiaError) {
return fail(502, "upstream_error", "The document could not be verified. Please try again.");
}
return fail(500, "internal_error", "Unexpected error.");
}
export function errorResponse(err: unknown): Response {
const e = toHttpError(err);
const headers = e.retryAfter ? { "Retry-After": String(e.retryAfter) } : undefined;
return Response.json(e.body, { status: e.status, headers });
}
export function languageFrom(raw: FormDataEntryValue | null): "es" | "en" | "pt" | "fr" {
try {
const value = JSON.parse(String(raw ?? "{}")).language;
return ["es", "en", "pt", "fr"].includes(value) ? value : "en";
} catch {
return "en";
}
}| SDK error | HTTP to your frontend | Meaning |
|---|---|---|
InvalidRequestError | the same (400, 409, 413, 415, 422) | Invalid file or request. The user can fix it. |
RateLimitError | 429 + Retry-After | You exceeded your key's requests per second. |
InsufficientCreditsError | 503 | No credits: alert your team. |
AuthenticationError, PermissionError | 500 | Missing, revoked or wrong key. |
APITimeoutError | 504 | The SDK hit its timeout. |
APIError, APIConnectionError | 502 | Constaia 5xx or network error. |
2. Routes
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("verify", "routes/verify.tsx"),
route("verify-form", "routes/verify-form.tsx"),
route("api/constaia", "routes/api.constaia.ts"),
route("api/webhooks/constaia", "routes/api.webhooks.constaia.ts"),
] satisfies RouteConfig;3. Upload resource route
A route without a default component is a resource route: its action answers JSON directly. The widget sends file and options (JSON with expect and language); the server sets expect and checks and only takes the language from the client.
import { errorResponse, getConstaia, languageFrom } from "~/lib/constaia.server";
import { getCurrentUser } from "~/lib/auth.server";
import { saveVerification } from "~/lib/verifications.server";
import type { Route } from "./+types/api.constaia";
export async function action({ request }: Route.ActionArgs) {
const user = await getCurrentUser(request);
if (!user) {
return Response.json({ error: { code: "unauthorized", message: "Please sign in to continue." } }, { status: 401 });
}
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) {
return Response.json({ error: { code: "file_required", message: "The file is missing." } }, { status: 400 });
}
try {
const analysis = await getConstaia().analyze(file, {
expect: "es_dni",
checks: { notExpired: true, minAgeYears: 18 },
language: languageFrom(form.get("options")),
metadata: { user_id: String(user.id) },
});
await saveVerification(user.id, analysis);
return Response.json(analysis, { status: analysis.status === "completed" ? 200 : 202 });
} catch (err) {
return errorResponse(err);
}
}getCurrentUser() and saveVerification() are yours (your session and your database). If the analysis takes longer than 30 s, Constaia returns 202 with status: "queued" or "processing"; the widget shows a "queued" notice and the result arrives through the webhook.
4. Page with the widget
The @constaia/widget/react wrapper registers the element only in the browser, so it works with SSR.
import { Link } from "react-router";
import { ConstaiaUpload, useConstaiaUpload } from "@constaia/widget/react";
export default function Verify() {
const { ref, result, error, reset } = useConstaiaUpload();
const verdict = result?.verdict?.status;
return (
<main>
<h1>Verify your ID</h1>
<ConstaiaUpload
ref={ref}
endpoint="/api/constaia"
document="es_dni"
lang="en"
onError={(e) => console.warn(e.code, e.message)}
/>
{verdict === "valid" && <Link to="/signup/details">Continue</Link>}
{verdict === "review" && <p>We could not read it well. Take another photo in good light, without glare.</p>}
{(verdict === "invalid" || error) && (
<button type="button" onClick={reset}>
Try another document
</button>
)}
</main>
);
}With document="es_dni" the widget asks for both sides and merges them into one JPEG. In the loader of /signup/details, check what you stored in saveVerification(); the browser's verdict is not proof.
5. Variant: Form and action
import { data, Form, useNavigation } from "react-router";
import { getConstaia, toHttpError } from "~/lib/constaia.server";
import { getCurrentUser } from "~/lib/auth.server";
import { saveVerification } from "~/lib/verifications.server";
import type { Route } from "./+types/verify-form";
export async function action({ request }: Route.ActionArgs) {
const user = await getCurrentUser(request);
if (!user) return data({ status: "error", messages: ["Please sign in to continue."] }, { status: 401 });
const file = (await request.formData()).get("file");
if (!(file instanceof File) || file.size === 0) {
return data({ status: "error", messages: ["Choose a photo or a PDF of the document."] }, { status: 400 });
}
try {
const analysis = await getConstaia().analyze(file, {
expect: "es_dni",
checks: { notExpired: true, minAgeYears: 18 },
language: "en",
metadata: { user_id: String(user.id) },
});
await saveVerification(user.id, analysis);
if (analysis.status !== "completed" || !analysis.verdict) return { status: "pending", messages: [] };
return {
status: analysis.verdict.status,
messages: analysis.verdict.reasons.filter((r) => r.severity !== "info").map((r) => r.message),
};
} catch (err) {
const e = toHttpError(err);
return data({ status: "error", messages: [e.body.error.message] }, { status: e.status });
}
}
export default function VerifyForm({ actionData }: Route.ComponentProps) {
const sending = useNavigation().state === "submitting";
return (
<main>
<h1>Upload your ID</h1>
<Form method="post" encType="multipart/form-data">
<input type="file" name="file" accept="image/jpeg,image/png,image/webp,image/heic,application/pdf" required />
<button type="submit" disabled={sending}>
{sending ? "Verifying…" : "Verify"}
</button>
</Form>
{actionData?.status === "valid" && <p>Valid document.</p>}
{actionData?.status === "pending" && <p>We are checking it. We will let you know when it is done.</p>}
{actionData?.messages.map((m) => (
<p key={m}>{m}</p>
))}
</main>
);
}Here the user uploads a single file: for both sides of the ID, one image with both or a two-page PDF (1 credit).
6. Webhook
Another resource route. Read the raw body with request.text() and verify before parsing.
import { type Analysis, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
import { getConstaia } from "~/lib/constaia.server";
import { markEventProcessed, updateVerification } from "~/lib/verifications.server";
import type { Route } from "./+types/api.webhooks.constaia";
export async function action({ request }: Route.ActionArgs) {
const secret = process.env.CONSTAIA_WEBHOOK_SECRET;
if (!secret) return new Response("CONSTAIA_WEBHOOK_SECRET is not set", { status: 500 });
const raw = await request.text();
let event: WebhookEvent;
try {
event = await getConstaia().webhooks.verify(raw, request.headers, secret);
} catch (err) {
if (err instanceof WebhookVerificationError) return new Response("invalid signature", { status: 400 });
throw err;
}
if (await markEventProcessed(request.headers.get("webhook-id") ?? "")) {
switch (event.type) {
case "analysis.completed":
case "analysis.review_required":
case "analysis.failed":
await updateVerification(event.data as Analysis);
break;
}
}
return new Response(null, { status: 204 });
}- Answer within 15 s; if the work is heavy, enqueue it.
markEventProcessed()(yours) stores thewebhook-idunder a unique key: retries repeat the same id.analysis.review_requiredarrives in addition toanalysis.completed;updateVerification()must be idempotent.
Register https://your-domain.com/api/webhooks/constaia in the dashboard or with constaia.webhookEndpoints.create() and store the secret. To test locally:
import { signWebhook } from "@constaia/sdk";
const payload = JSON.stringify({
type: "analysis.completed",
created_at: new Date().toISOString(),
data: { id: "an_test", object: "analysis", status: "completed" },
});
const headers = await signWebhook(payload, process.env.CONSTAIA_WEBHOOK_SECRET);
const res = await fetch("http://localhost:5173/api/webhooks/constaia", {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: payload,
});
console.log(res.status);node --env-file=.env scripts/send-test-webhook.mjsRemix v2
With Remix v2 and flat file routes you do not need app/routes.ts: app/routes/api.constaia.ts already answers at /api/constaia. Change the types and imports:
import type { ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
export async function action({ request }: ActionFunctionArgs) {
// same body as above
}In the component, read the result with useActionData<typeof action>() instead of the actionData prop, and use json() from @remix-run/node (or Response.json) instead of data().
7. Test mode
With ck_test_... the result depends on the file name, which must be a real image or PDF. The widget merges both sides into a JPEG named after the front.
| File | verdict.status | Main reason |
|---|---|---|
dni_valid.jpg | Válido | not_expired (info): "Valid until 12/03/2031." |
dni_expired.jpg | No válido | not_expired (error): expired on 15/06/2020 |
blurry.jpg | Revisar | low_quality (warning); warnings: blurry, low_quality |
photo.jpg (any other name) | No válido | type_mismatch: generic is detected |
More names in Test mode.
Production
- Authenticate and rate-limit
/api/constaiaand the form action: every call spends credits. - Body size:
request.formData()loads the file into memory and the React Router server sets no limit of its own, but your proxy or platform may. Allow at least 20 MB plus multipart overhead (nginx:client_max_body_size 25m;) or set the widget'smax-size-mb. - Timeouts: a synchronous analysis waits up to 30 s and the SDK uses 60 s per attempt. Tune proxy timeouts or your serverless functions' maximum duration.
- Live key only in production and a webhook endpoint created with the live key.
- Decide on the server with the stored result or
GET /v1/analyses/{id}. - Review Rate limits and Errors.
Next steps
SvelteKit
Validate documents in SvelteKit with a +server.ts endpoint or a form action, $env/static/private, the widget loaded in onMount and a signed webhook.
Astro
Validate documents in Astro with an SSR adapter, a src/pages/api/constaia.ts endpoint, the widget loaded through a script tag and a signed webhook.