Get Started
Write a form's constraints once, in a zod schema. @k8ordo/form derives from it the constraint attributes and messages the browser gets, and the typed validation the Server Action runs. The client-side checks are derived, never written by hand.
The idea
Form validation is usually written twice: required and maxLength in the JSX, and a validation routine on the server. The two are edited separately, so sooner or later they disagree. This package keeps one place to write it and derives the rest.
- The schema is the only source. Attributes such as
required,minLengthandtype="email", the message shown next to a field, and the validation on the server all come from it. - The DOM holds the values. React state carries only the messages on screen, the server errors that are still current, the identity of each repeated row, one dirty flag, and the row counts that adding or removing a row is measured against. Values are never copied into state, so the form does not re-render on every keystroke.
- It works without JavaScript. The constraint attributes are in the HTML the server sends, so the browser validates on its own with scripts disabled or not yet loaded.
- The server decides. Some checks have no HTML attribute, so
parseFormvalidates every submission against the same schema and answers with per-field errors and the submitted values.
Installation
Add @k8ordo/form and zod, which the schema is written in.
npm install @k8ordo/form zodThe peer dependencies are below. TypeScript and @types/react are needed only for the shipped type declarations.
| Package | Version | Needed for |
|---|---|---|
react | >=19.3.0 | useForm and Server Actions |
react-dom | >=19.3.0 | Rendering |
zod | ^4.4.3 | The schema (zod/mini works too) |
typescript | >=7.0.2 | The shipped type declarations (optional) |
@types/react | >=19.3.0 | The shipped type declarations (optional) |
zod or zod/mini
The conversion reads zod's shared core, so a schema written with either entry works, and nothing in the package notices which one you chose. Only the Server Component and the Server Action import the schema, so zod does not reach the browser either way. When the client imports the schema module too — a GET form with @k8ordo/state, for instance — zod/mini keeps that bundle small.
import * as z from 'zod';
export const talkSchema = z.object({
title: z
.string()
.min(1, 'Enter a title')
.max(120, 'Use 120 characters or fewer'),
});import * as z from 'zod/mini';
export const talkSchema = z.object({
title: z
.string()
.check(
z.minLength(1, 'Enter a title'),
z.maxLength(120, 'Use 120 characters or fewer'),
),
});zod/mini bundles no locale, so its default message is Invalid input — unless the classic zod entry is also in use in the same process, which installs its English locale globally. The text next to a field is zod's own too, so either write the messages yourself or load a locale, as in z.config(z.locales.ja()).
The server/client split
There are two entry points. @k8ordo/form/server is the side that reads the schema; @k8ordo/form holds the hooks that run in the browser. A form is put together in three steps.
- Call
formFields(schema)in a Server Component or at its module scope. The result is plain JSON — attributes, messages, rules and thedroppedreport — with no functions and no zod. - Pass the result to a client component as a prop and hand it to
useForm(fields, state). Being JSON, it crosses the RSC boundary, and zod stays out of the client bundle. - The Server Action receives the submission, and
parseForm(schema, formData)returns either typed data or astateholding per-field errors.
| Entry | Values | Types |
|---|---|---|
@k8ordo/form/server | formFields, parseForm, defineForm, sameAs, minChecked, requiredWhen | ParseResult, FormDefinition |
@k8ordo/form | useForm, useAsyncCheck, HiddenValue | UseFormReturn, FieldView, ArrayView, RowView, AsyncCheck |
| Both | — | FormFields, FormState, DerivedField, DerivedArray, DroppedCheck, FieldInput, ValidityFlag, Rule |
The types of the formFields result and of the action state are exported from both entries, since the result shows up on the client as a prop.
A complete form
A form that registers a talk, in four files: the schema, the page, the Server Action and the form. The example is an @k8ordo/server application, which has Server Actions.
1. The schema
Type conversion belongs in the schema. Every submitted value is a string, so a number is read with z.coerce.number(). An empty numeric field arrives as nothing entered, not as 0, so the wording for an untouched field is the argument to z.coerce.number(). Depending on the zod version, that wording also stands in for any check without its own (4.5.4 does this, 4.4.3 does not), so give .int() and .min() theirs.
// src/routes/talks/new/_parts/talk-schema.ts
import * as z from 'zod';
export const talkSchema = z.object({
title: z
.string()
.min(1, 'Enter a title')
.max(120, 'Use 120 characters or fewer'),
eventUrl: z.url('Enter the event URL'),
minutes: z.coerce
.number('Enter the length in minutes')
.int('Use whole minutes')
.min(5, 'A talk is at least 5 minutes'),
recorded: z.boolean(),
});2. The page (Server Component)
formFields runs once, at module scope. The result is the same for every request, so there is no reason to derive it on each render. Call it inside the render only when the messages follow the request, such as its language.
// src/routes/talks/new/page.tsx
import { formFields } from '@k8ordo/form/server';
import { TalkForm } from './_parts/talk-form';
import { talkSchema } from './_parts/talk-schema';
const talkFields = formFields(talkSchema);
export default function NewTalkPage() {
return <TalkForm fields={talkFields} />;
}3. The Server Action
When parseForm fails, return parsed.state as it is. It holds the per-field errors and the submitted values (never passwords or files), so a retry keeps what was typed, with or without JavaScript. On success, parsed.data has the schema's output type.
// src/routes/talks/new/_parts/actions.ts
'use server';
import { parseForm } from '@k8ordo/form/server';
import type { FormState } from '@k8ordo/form/server';
import { redirect } from '@k8ordo/server/runtime';
import { talkSchema } from './talk-schema';
import { insertTalk } from './talks.server';
export async function createTalk(
_previous: FormState,
formData: FormData,
): Promise<FormState> {
const parsed = parseForm(talkSchema, formData);
if (!parsed.success) return parsed.state;
await insertTalk(parsed.data);
redirect('/talks');
}4. The form (client component)
useForm returns a UseFormReturn: props, field, array and isDirty. Spread form.props onto the <form>; there is no per-field registration. Spread the input that field(path) returns onto the control, and show error when there is one. Paths are typed from the schema, so a typo stops at compile time. In the props type, FormFields<FieldPath, ArrayPath> takes the field() paths first and the array() paths second (never when there are none).
// src/routes/talks/new/_parts/talk-form.tsx
'use client';
import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';
import { createTalk } from './actions';
type Props = {
fields: FormFields<'title' | 'eventUrl' | 'minutes' | 'recorded', never>;
};
export function TalkForm({ fields }: Props) {
const [state, formAction] = useActionState(createTalk, {});
const form = useForm(fields, state);
const title = form.field('title');
const eventUrl = form.field('eventUrl');
const minutes = form.field('minutes');
const recorded = form.field('recorded');
return (
<form {...form.props} action={formAction}>
<label>
Title
<input {...title.input} aria-invalid={title.invalid} />
</label>
{title.error !== undefined && <p>{title.error}</p>}
<label>
Event URL
<input {...eventUrl.input} aria-invalid={eventUrl.invalid} />
</label>
{eventUrl.error !== undefined && <p>{eventUrl.error}</p>}
<label>
Minutes
<input {...minutes.input} aria-invalid={minutes.invalid} />
</label>
{minutes.error !== undefined && <p>{minutes.error}</p>}
<label>
<input {...recorded.input} />
Recorded
</label>
{state.formError !== undefined && <p>{state.formError}</p>}
<button type="submit">Register</button>
</form>
);
}form.props is ref, onBlur, onInput and onReset. Writing a prop of the same name on the <form> yourself leaves only the one written last in effect, which can disconnect useForm's validation or reset handling.
To avoid listing the paths in the props type by hand, bring in the schema and formFields with import type and write ReturnType<typeof formFields<typeof talkSchema>>. A type-only import puts neither zod nor the schema into the client bundle.
// src/routes/talks/new/_parts/talk-form.tsx
'use client';
import type { formFields } from '@k8ordo/form/server';
import type { talkSchema } from './talk-schema';
type Props = {
fields: ReturnType<typeof formFields<typeof talkSchema>>;
};What happens on submit
- Without JavaScript, the browser validates against the constraint attributes and stops a submission that fails, in its own wording.
- With JavaScript,
useFormsetsnoValidateon the form and shows zod's wording when a field is left. It does not block the submission; the submission goes to the Server Action. - When
parseFormreports a failure, each error appears next to its field, and focus moves to the first field listed instate.errors.
Next steps
- Fields — the attributes each zod type derives; nested objects, repeated rows, checkbox groups and files
- Validation — how messages behave, what
parseFormreturns, cross-field rules and server-answered checks - Patterns — multi-step forms, GET forms with @k8ordo/state, and pairing with @k8ordo/ui
- A live demo — the GET form on the @k8ordo/form landing page