Validate Chilean RUT in Next.js Server Actions with Zod
Type-safe Chilean RUT validation in Next.js App Router Server Actions with rut.ts and Zod — one shared schema, `useActionState`, and progressive enhancement.
Server Actions in the Next.js App Router collapse the client/server seam for form submissions. With rut.ts and a shared Zod schema, both sides of the wire agree on what a valid Chilean RUT looks like — and the network round trip becomes invisible. rut.ts is zero-dependency and TypeScript-native, so there's no extra weight in the server bundle and no client bundle bloat.
How do you validate a Chilean RUT in a Next.js Server Action?#
Define a Zod schema in a shared module that calls validate(value, { strict: true }) from rut.ts inside .refine(), import the same schema from both the "use client" form and the "use server" action, and run safeParse on the server. The client gets fast field-level feedback; the server runs the same gate as the authoritative trust boundary.
The classic alternative is two separate schemas: one in the form component and one in an API route handler. Schema drift follows quickly — a constraint tightened on one side, a field added on the other. Server Actions close that gap structurally. Because the form component and the action share a module graph, a single Zod schema can be imported by both. The schema is the canonical definition, not a copy.
The shared schema#
Define the Zod schema in a shared module so client and server import the same source of truth.
The validate(v, { strict: true }) refinement is doing more than a format check. It runs the Modulo 11 checksum, verifies the verifier digit, and blocks repeated-digit placeholders like 11.111.111-1 that are technically valid checksums but never real identities. Strict mode is the right default for any context involving real user accounts. The .trim() call runs before the refinement, so a value copied from a spreadsheet with surrounding whitespace passes without requiring the user to clean their own input.
// app/checkout/schema.ts
import { z } from "zod";
import { validate } from "rut.ts";
export const checkoutSchema = z.object({
rut: z
.string()
.trim()
.refine((v) => validate(v, { strict: true }), {
message: "Enter a valid Chilean RUT",
}),
name: z.string().min(2),
});
export type CheckoutValues = z.infer<typeof checkoutSchema>;Exporting the inferred type means the rest of the codebase can import CheckoutValues without maintaining a parallel interface. The type is always in sync with the schema by construction.
The Server Action#
The action lives in a separate file marked "use server". This directive tells Next.js to keep the module on the server: the function is never included in the client bundle, and callers receive a reference to an RPC endpoint rather than the function itself.
checkoutSchema.safeParse() is the gate. It returns a discriminated union — { success: true, data } or { success: false, error } — so the logic after it is type-narrowed. There is no try/catch needed and no manual field checking. parsed.error.flatten().fieldErrors produces a plain object keyed by field name, which is a serializable shape the client can render without any additional transformation.
The call to format(parsed.data.rut)! on the success path deserves a note. format() returns null for invalid input, but the input here has already passed validate({ strict: true }), so the null case is impossible. The non-null assertion is safe given that ordering assumption. If you ever remove or relax the Zod validation, drop the ! and handle the null explicitly.
Re-running checkoutSchema on the server is what makes this a trust boundary. The client schema is for UX: it surfaces errors quickly and avoids a network round trip for obviously invalid input. The server schema is the gate: it runs regardless of what the client sent, because the caller of any Server Action might be a script or a modified browser, not a React form. Those two purposes are served by the same schema, but the server run is not optional.
// app/checkout/actions.ts
"use server";
import { checkoutSchema } from "./schema";
import { format } from "rut.ts";
export type CheckoutResult =
| { ok: true; rut: string }
| { ok: false; fieldErrors: Record<string, string[]> };
export async function submitCheckout(
_prev: CheckoutResult | null,
formData: FormData,
): Promise<CheckoutResult> {
const parsed = checkoutSchema.safeParse({
rut: formData.get("rut"),
name: formData.get("name"),
});
if (!parsed.success) {
return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
}
// Canonicalize for persistence; format() validates on the safe path.
const canonical = format(parsed.data.rut)!;
// ...persist canonical here
return { ok: true, rut: canonical };
}The client form#
useActionState is the React 19 hook that wires a Server Action into a form. It returns the last action result, a bound form action reference, and the pending boolean. The form's action attribute receives that reference — not an event handler — and that is what enables progressive enhancement.
When JavaScript has not loaded, the browser posts the form natively and the Server Action still runs. The error rendering reads from state.fieldErrors.rut, role="alert" announces errors to screen readers, and disabled={pending} blocks double-submission.
// app/checkout/page.tsx
"use client";
import { useActionState } from "react";
import { submitCheckout } from "./actions";
export default function CheckoutPage() {
const [state, formAction, pending] = useActionState(submitCheckout, null);
return (
<form action={formAction}>
<label>
Name
<input name="name" required />
</label>
<label>
RUT
<input name="rut" inputMode="numeric" required />
{state && !state.ok && state.fieldErrors.rut?.[0] ? (
<span role="alert">{state.fieldErrors.rut[0]}</span>
) : null}
</label>
<button disabled={pending}>Continue</button>
</form>
);
}Why this is good#
Progressive enhancement comes for free. The form posts and works without JavaScript because Next.js renders a real <form action> that targets the server action endpoint. A user on a slow connection or with scripting disabled submits the same form and gets the same validation result. No separate fallback is required.
One Zod schema, two trust boundaries. The client imports checkoutSchema for field-level feedback. The server imports the same checkoutSchema as the acceptance gate. They cannot drift because they are the same object. Adding a field, changing a message, or tightening a constraint happens in one place and propagates to both.
No API route to maintain. There is no POST /api/checkout handler, no route file, and no fetch call to write. The contract is the action signature: (prev: CheckoutResult | null, formData: FormData) => Promise<CheckoutResult>. TypeScript enforces that contract across the import boundary.
CheckoutResult is a discriminated union. The ok boolean discriminant means the client can narrow the type correctly without extra type guards. if (!state.ok) is enough to make state.fieldErrors available in the type-narrowed branch.
Pitfalls#
Don't validate only on the client. The whole point of running safeParse in the server action is that the network is not a trust boundary. Any client with curl can craft a request that skips your form entirely. The server parse is not a courtesy — it is the actual gate.
Don't trust FormData.get() to give you a string. It returns File | null if a field is missing or submitted as a file upload. The Zod schema's string() call catches this — safeParse will fail on a File or null value — but be aware of the failure mode if you ever access FormData values before the schema runs.
Don't return raw parsed.error from the server. error.flatten().fieldErrors is the shape the client knows how to render. Returning the full ZodError object can leak schema structure and is not a serializable value in all contexts. Flatten first, then return.
format(parsed.data.rut)! with a non-null assertion is safe only because validate({ strict: true }) already ran. If you remove the Zod refinement, skip the strict flag, or reorder the validation steps, the assumption breaks. Drop the ! and handle the null return explicitly whenever the strict gate is not guaranteed to precede the format() call.
Further reading#
- Install rut.ts —
pnpm add rut.ts, zero dependencies, TypeScript-native - Quick start — five minutes from install to a hardened Server Action
validate()reference- Chilean RUT in React Hook Form: register vs Controller
- Hardening RUT acceptance: strict mode and safe processing