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:

apps/web/app/[locale]/contact/actions/contact.tsx
'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, not error. 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. parseError records 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:

SchemaNotes
emailTrimmed and lowercased before validating, max 320
shortText1–200 characters
longText1–5000 characters
slugLowercase, numbers and hyphens
cuidBounded identifier
internalPathA path on this site, and nothing else
honeypotMust be empty
paginationcursor 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.