Build a server-validated form
Use definePage().withForm() when a Fresh page needs route-bound form state, server validation,
mutation, and redirect or same-page success handling in one typed page definition.
Prerequisites
- A Fresh route that can render a NetScript page definition.
- A schema object accepted by the form runtime.
- A server mutation that receives validated input.
- A component that receives
RuntimeFormStateprops.
Define the form
withForm(id, component, config) registers a typed layer and a method handler. The config surface
includes schema, initial, mutate, onIntent, redirectTo, onSuccess, invalidate, csrf,
method, and spanName.
import { definePage } from '@netscript/fresh';
import { z } from 'zod';
const ContactSchema = z.object({
email: z.string().email(),
message: z.string().min(10),
});
function ContactForm(props: {
values: Record<string, unknown>;
errors?: Record<string, readonly string[]>;
message?: string;
}) {
return (
<form method="post">
<input name="email" value={String(props.values.email ?? '')} />
<textarea name="message">{String(props.values.message ?? '')}</textarea>
<button type="submit">Send</button>
</form>
);
}
export const contactPage = definePage()
.withForm('contact', ContactForm, {
schema: ContactSchema,
method: 'POST',
csrf: true,
initial: () => ({ email: '', message: '' }),
mutate: async (input) => {
const ticket = await createSupportTicket(input);
return { ticketId: ticket.id };
},
redirectTo: (output) => `/support/thanks?id=${output.ticketId}`,
spanName: 'form.contact',
})
.build('/contact');
Render validation errors
Validation failures stay on the form layer. Keep field rendering defensive: read values, show
field errors when present, and avoid assuming the mutation ran.
function FieldError(props: { errors?: readonly string[] }) {
if (!props.errors?.length) return null;
return <p class="field-error">{props.errors[0]}</p>;
}
Test the page definition
Keep one unit test around the builder chain so method, schema, and layer IDs stay wired.
import { assertEquals } from '@std/assert';
import { contactPage } from './contact.page.tsx';
Deno.test('contact page registers the route pattern', () => {
assertEquals(contactPage.routePattern, '/contact');
});
Failure modes
- Missing
schema: the form has no server validation boundary. mutatethrows: the form runtime returns an error state instead of runningredirectTo.redirectToandonSuccessboth exist:redirectTotakes precedence.- CSRF is enabled by default; disable it only for a route that has another verified protection.
Next steps
- Use the page-builder model from Fresh meta-framework.
- Connect the page to typed data with Live Dashboard, chapter 04.
- Look up the builder surface in fresh reference.