Remix y React Router
Valida documentos en Remix y React Router v7 (modo framework) con una action que lee request.formData(), el widget de React y una resource route de webhook.
En esta guía montas la verificación de un DNI en React Router v7 en modo framework (el sucesor de Remix). Las mismas piezas sirven para Remix v2 con cambios mínimos, indicados al final.
- Una resource route
app/routes/api.constaia.tscuyaactionrecibe el archivo del widget y llama a Constaia con el SDK de JavaScript. - Una página con el widget mediante el wrapper de React.
- Una variante sin widget: una ruta con
<Form>yaction. - Una resource route de webhook que verifica la firma con
request.text().
La clave vive en un módulo .server.ts, que el bundler nunca incluye en el código del navegador.
Requisitos
- React Router v7 en modo framework (o Remix v2) sobre Node ≥ 18.
- Una clave de test
ck_test_...del panel.
Instalación
npm i @constaia/sdk @constaia/widgetVariables de entorno
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...Carga el .env con tu servidor (por ejemplo node --env-file=.env o dotenv). Estas variables solo se leen en módulos .server.ts, loaders y actions.
1. Cliente y errores
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", "Hay mucha demanda. Vuelve a intentarlo en unos segundos.", err.retryAfter);
}
if (err instanceof InsufficientCreditsError) {
return fail(503, "verification_unavailable", "La verificación no está disponible ahora mismo.");
}
if (err instanceof AuthenticationError || err instanceof PermissionError) {
return fail(500, "server_misconfigured", "Error de configuración del servidor.");
}
if (err instanceof APITimeoutError) {
return fail(504, "timeout", "La verificación ha tardado demasiado. Inténtalo de nuevo.");
}
if (err instanceof ConstaiaError) {
return fail(502, "upstream_error", "No se ha podido verificar el documento. Inténtalo de nuevo.");
}
return fail(500, "internal_error", "Error inesperado.");
}
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 : "es";
} catch {
return "es";
}
}| Error del SDK | HTTP hacia tu frontend | Qué significa |
|---|---|---|
InvalidRequestError | el mismo (400, 409, 413, 415, 422) | Archivo o petición no válidos. El usuario puede corregirlo. |
RateLimitError | 429 + Retry-After | Superaste las peticiones por segundo de tu clave. |
InsufficientCreditsError | 503 | Sin créditos: avisa a tu equipo. |
AuthenticationError, PermissionError | 500 | Clave ausente, revocada o incorrecta. |
APITimeoutError | 504 | El SDK agotó su timeout. |
APIError, APIConnectionError | 502 | Error 5xx de Constaia o de red. |
2. Rutas
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. Resource route de subida
Una ruta sin componente por defecto es una resource route: su action responde JSON directamente. El widget envía file y options (JSON con expect y language); el servidor fija expect y checks y solo toma el idioma del cliente.
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: "Inicia sesión para continuar." } }, { 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: "Falta el archivo." } }, { 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() y saveVerification() son tuyas (tu sesión y tu base de datos). Si el análisis tarda más de 30 s, Constaia responde 202 con status: "queued" o "processing"; el widget muestra un aviso de "en cola" y el resultado llega por el webhook.
4. Página con el widget
El wrapper @constaia/widget/react registra el elemento solo en el navegador, así que funciona con 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>Verifica tu DNI</h1>
<ConstaiaUpload
ref={ref}
endpoint="/api/constaia"
document="es_dni"
lang="es"
onError={(e) => console.warn(e.code, e.message)}
/>
{verdict === "valid" && <Link to="/registro/datos">Continuar</Link>}
{verdict === "review" && <p>No se lee bien. Haz otra foto con buena luz y sin reflejos.</p>}
{(verdict === "invalid" || error) && (
<button type="button" onClick={reset}>
Probar con otro documento
</button>
)}
</main>
);
}Con document="es_dni" el widget pide las dos caras y las une en un JPEG. En el loader de /registro/datos, comprueba lo que guardaste en saveVerification(); el veredicto del navegador no es una prueba.
5. Variante: Form y 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: ["Inicia sesión para continuar."] }, { status: 401 });
const file = (await request.formData()).get("file");
if (!(file instanceof File) || file.size === 0) {
return data({ status: "error", messages: ["Selecciona una foto o un PDF del documento."] }, { status: 400 });
}
try {
const analysis = await getConstaia().analyze(file, {
expect: "es_dni",
checks: { notExpired: true, minAgeYears: 18 },
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>Sube tu DNI</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 ? "Verificando…" : "Verificar"}
</button>
</Form>
{actionData?.status === "valid" && <p>Documento válido.</p>}
{actionData?.status === "pending" && <p>Lo estamos revisando. Te avisaremos al terminar.</p>}
{actionData?.messages.map((m) => (
<p key={m}>{m}</p>
))}
</main>
);
}Aquí el usuario sube un único archivo: para las dos caras del DNI, una imagen con ambas o un PDF de dos páginas (1 crédito).
6. Webhook
Otra resource route. Lee el cuerpo crudo con request.text() y verifica antes de parsear.
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 });
}- Responde en menos de 15 s; si el trabajo es pesado, encólalo.
markEventProcessed()(tuya) guarda elwebhook-idcon clave única: los reintentos repiten el mismo id.analysis.review_requiredllega además deanalysis.completed;updateVerification()debe ser idempotente.
Registra https://tu-dominio.com/api/webhooks/constaia en el panel o con constaia.webhookEndpoints.create() y guarda el secret. Para probar en local:
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
Con Remix v2 y las rutas planas por convención no necesitas app/routes.ts: app/routes/api.constaia.ts ya responde en /api/constaia. Cambia los tipos e imports:
import type { ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
export async function action({ request }: ActionFunctionArgs) {
// mismo cuerpo que arriba
}En el componente, lee el resultado con useActionData<typeof action>() en lugar de la prop actionData, y usa json() de @remix-run/node (o Response.json) en lugar de data().
7. Probar en modo test
Con ck_test_... el resultado depende del nombre del archivo, que debe ser una imagen o PDF real. El widget une las dos caras en un JPEG con el nombre del anverso.
| Archivo | verdict.status | Motivo principal |
|---|---|---|
dni_valid.jpg | Válido | not_expired (info): "Vigente hasta el 12/03/2031." |
dni_expired.jpg | No válido | not_expired (error): "Caducado el 15/06/2020." |
blurry.jpg | Revisar | low_quality (warning); warnings: blurry, low_quality |
foto.jpg (otro nombre) | No válido | type_mismatch: se detecta generic |
Más nombres en Modo test.
Producción
- Autentica y limita
/api/constaiay la action del formulario: cada llamada gasta créditos. - Tamaño del cuerpo:
request.formData()carga el archivo en memoria y el servidor de React Router no impone un límite propio, pero tu proxy o plataforma sí puede. Permite al menos 20 MB más el overhead del multipart (nginx:client_max_body_size 25m;) o ajustamax-size-mben el widget. - Tiempos: el análisis síncrono espera hasta 30 s y el SDK usa 60 s por intento. Ajusta los timeouts del proxy o la duración máxima de tus funciones serverless.
- Clave live solo en producción y un endpoint de webhook creado con la clave live.
- Decide en el servidor con el resultado guardado o
GET /v1/analyses/{id}. - Revisa Límites de uso y Errores.
Siguientes pasos
SvelteKit
Valida documentos en SvelteKit con un endpoint +server.ts o una form action, $env/static/private, el widget cargado en onMount y un webhook firmado.
Astro
Valida documentos en Astro con un adaptador SSR, un endpoint src/pages/api/constaia.ts, el widget cargado con una etiqueta script y un webhook firmado.