Validation errors have one shape everywhere: a field name mapped to the messages for it.
{ title: ['The title must be at least 3 characters.'], body: ['The body is required.'] }ServerValidationError carries that shape, and <Form>
catch it and put it in errors. Nothing in a component has to know where the
validation ran.
In a form
"use client";
import { Form } from '@rsc-kit/core/Form';
import { createPost } from '../actions';
export function PostForm() {
return (
<Form action={createPost}>
{({ errors, pending }) => (
<>
<input name="title" />
{errors.title?.map((message) => <p key={message} className="error">{message}</p>)}
<textarea name="body" />
{errors.body?.map((message) => <p key={message} className="error">{message}</p>)}
<button disabled={pending}>Create</button>
</>
)}
</Form>
);
}No try/catch, and no state of your own. A successful submit clears
errors; a failed one fills them.
Outside a form
Catch it yourself, wherever the call rejects with one:
"use client";
import { ServerValidationError } from '@rsc-kit/core/errors';
import { createPost } from '../actions';
try {
await createPost(title, body);
} catch (error) {
if (error instanceof ServerValidationError) {
setErrors(error.errors);
}
}Producing one
Before it is submitted
<Form> takes any Standard Schema —
Zod, Valibot, ArkType, or anything else implementing it. There is no adapter
per library and no import of one: the schema is asked to validate itself.
"use client";
import { z } from 'zod';
import { Form } from '@rsc-kit/core/Form';
import { createPost } from '../actions';
const schema = z.object({
title: z.string().min(3, 'Too short'),
body: z.string().min(10, 'Too short'),
});
export function PostForm() {
return (
<Form action={createPost} schema={schema}>
{({ errors, pending }) => (
<>
<input name="title" />
{errors.title?.map((m) => <p key={m} className="error">{m}</p>)}
<textarea name="body" />
{errors.body?.map((m) => <p key={m} className="error">{m}</p>)}
<button disabled={pending}>Create</button>
</>
)}
</Form>
);
}A failure fills the same errors you were already rendering and the action is
never called — so a mistake costs no round trip, and an optimistic update never
shows a row the server was going to refuse. Async schemas are awaited, so a
uniqueness check works the same way.
Field names follow the schema’s paths: a nested field is address.city, and an
issue belonging to no field — a cross-field rule like “these must match” — is
under the empty string, so it has somewhere to be shown rather than being
dropped.
In a JavaScript action
The same schema, on the side that decides. An action returns its failure rather than throwing one, so one line turns that into the error the form already understands:
'use server'
import { schema } from './schema';
export async function createPost(title: string, body: string) {
const parsed = await schema['~standard'].validate({ title, body });
if (parsed.issues) {
return { ok: false as const, issues: parsed.issues };
}
return { ok: true as const, post: await savePost(parsed.value) };
}import { ServerValidationError } from '@rsc-kit/core/errors';
import { issuesToErrors } from '@rsc-kit/core/standardSchema';
async function submit(formData: FormData) {
const result = await createPost(
formData.get('title') as string,
formData.get('body') as string,
);
if (!result.ok) throw new ServerValidationError('Validation failed', issuesToErrors(result.issues));
return result.post;
}issuesToErrors is the same mapping the client-side check uses, so the field
names match whichever side produced them.
ServerValidationError
| Member | Type | What it holds |
|---|---|---|
errors |
Record<string, string[]> |
Field name to messages. |
message |
string |
A summary, for when there is nowhere to put field errors. |