--- title: Validation description: Parsing untrusted input at every boundary, with Zod. type: reference related: - /en/docs/packages/security/application - /en/docs/setup/env --- # Validation `@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. ```ts 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: ```ts title="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: ```ts type ActionResult = | { ok: true; data: T } | { ok: false; error: string; fields?: Record }; ``` `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: ```ts 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. --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt)