Validation
Parsing untrusted input at every boundary, with Zod.
@kreogen/validation is the boundary layer. Environment variables are
validated by each package's keys(); everything else that arrives from outside
the process goes through here.
import {
defineAction,
parseRequestBody,
parseSearchParams,
schemas,
} from '@kreogen/validation';Why server actions need this
A server action is a public HTTP endpoint. Next gives each one a stable id and the browser POSTs to it directly, so anyone who has loaded the page can call it with arguments of their choosing.
Its TypeScript signature is, from the caller's point of view, a compile-time
fiction. An action taking (name: string, email: string) will happily receive
an object, a ten-megabyte string, or nothing at all.
defineAction makes the schema the entry point:
'use server';
import { defineAction, schemas } from '@kreogen/validation';
export const contact = defineAction({
name: 'contact.submit',
input: schemas.contactForm,
handler: async ({ name, email, message }) => {
// Already parsed. The argument type comes from the schema.
return { sent: true };
},
});The handler cannot run until the input parses, and its argument type is derived from the schema rather than from a hopeful annotation.
What it returns
A discriminated result rather than a thrown error, so the caller has to deal with failure:
type ActionResult<T> =
| { ok: true; data: T }
| { ok: false; error: string; fields?: Record<string, string> };fields is keyed by dotted path, matching the input names in a form, so
rendering per-field errors needs no traversal at the call site.
Two behaviours are worth knowing:
- Invalid input is logged at
info, noterror. A client mistake or a probe is not a fault in the service, and paging on it trains people to ignore the channel. The values are deliberately not logged — this is exactly where passwords and tokens turn up. - Thrown errors do not reach the browser.
parseErrorrecords the failure; the caller gets a generic message. An exception here can carry a connection string or a provider's raw response, and nothing at the call site can tell a safe message from an unsafe one.
Route handlers
Same treatment, returning a Response rather than throwing, so the guard reads
as a guard:
const parsed = await parseRequestBody(request, schema);
if (!parsed.ok) {
return parsed.response;
}A malformed body — one that is not JSON at all — is distinguished from a well-formed body that fails the schema, because they mean different things about the caller.
parseSearchParams handles the same for query strings. Repeated keys become
arrays and single keys stay scalar, because ?tag=a&tag=b is meaningful and
collapsing params to a plain object silently drops everything but the last
value.
Shared schemas
schemas exports primitives so that "an email" means the same thing in a
contact form as in an invitation:
| Schema | Notes |
|---|---|
email | Trimmed and lowercased before validating, max 320 |
shortText | 1–200 characters |
longText | 1–5000 characters |
slug | Lowercase, numbers and hyphens |
cuid | Bounded identifier |
internalPath | A path on this site, and nothing else |
honeypot | Must be empty |
pagination | cursor plus a limit between 1 and 100, default 25 |
Every string has an upper bound. An unbounded text field is a free denial of service: the body is buffered, validated, and often rendered into an email or a database column before anyone thinks about its size.
The email normalisation order matters. .trim().toLowerCase() chained onto
z.email() are transforms that run after the format check, so
" Ada@Example.com " would be rejected for the whitespace it was about to have
removed. The pipe puts them in the right order.
internalPath rejects anything not starting with exactly one slash.
//evil.example is the protocol-relative open redirect that a naive
startsWith('/') check waves straight through.
honeypot is a field no human fills in. Bots complete every input they find,
so a non-empty value is a strong signal — and a cheap one, needing no third
party and nothing from the user.
Where to use it
Anywhere data crosses into the process: server actions, route handlers, webhook
payloads, search params, and anything read from a CMS or a queue. A type
assertion is not validation, and as is how untrusted data gets a trusted type
without anyone checking.