Conform
Conform — progressive enhancement forms, Server Actions, Zod integration, nested objects, arrays, intent buttons, and accessibility patterns
Conform is a progressive enhancement-first form library designed for modern React frameworks like Next.js App Router and Remix. It works by reading form data from the native `FormData` API, not from controlled React state. This means forms function even before JavaScript loads — the server processes the submission, validates the data, and returns errors that Conform maps back to the correct fields. Once JS hydrates, Conform adds client-side validation and enhanced UX without changing the underlying mechanism. This architecture aligns perfectly with React Server Components and Server Actions. ## Key Points - **Share the Zod schema** between the server action and client `onValidate` — this guarantees identical validation rules on both sides. - **Always include `form.id` on the form element** — Conform uses it to associate fields and manage state correctly. - **Use `defaultValue` instead of `value`** — Conform works with uncontrolled inputs. Using `value` requires manual `onChange` handling and breaks progressive enhancement. - **Set `shouldValidate: "onBlur"` and `shouldRevalidate: "onInput"`** for the best UX — users see errors after leaving a field, and errors disappear immediately when corrected. - **Use `submission.reply()`** consistently — it serializes the form state (values, errors) in a format that `useForm` can consume via `lastResult`. - **Add `noValidate`** to the form element to suppress native browser validation when JS is active, while preserving it as a fallback when JS fails to load. - **Prefer intent buttons over separate forms** when a single form can serve multiple purposes (draft vs. publish, delete vs. update). - **Using `useState` to manage field values** — this defeats Conform's progressive enhancement model. Let native form data flow through `FormData`. - **Calling `e.preventDefault()` manually** — use `form.onSubmit` which handles this for you and integrates with the validation lifecycle. - **Ignoring `lastResult`** — without passing the server's reply back to `useForm`, server-side errors will not display on the client. - **Skipping `aria-invalid` and `aria-describedby`** — Conform provides these values for free; omitting them makes forms inaccessible to screen readers. - **Wrapping Conform fields in controlled components** — libraries like MUI require extra work with Conform; prefer native HTML inputs or use the `getInputProps` helpers to bridge the gap. ## Quick Example ```typescript npm install @conform-to/react @conform-to/zod zod ```
skilldb get forms-validation-skills/ConformFull skill: 325 linesConform
Core Philosophy
Conform is a progressive enhancement-first form library designed for modern React frameworks like Next.js App Router and Remix. It works by reading form data from the native FormData API, not from controlled React state. This means forms function even before JavaScript loads — the server processes the submission, validates the data, and returns errors that Conform maps back to the correct fields. Once JS hydrates, Conform adds client-side validation and enhanced UX without changing the underlying mechanism. This architecture aligns perfectly with React Server Components and Server Actions.
Setup
npm install @conform-to/react @conform-to/zod zod
Basic Server Action form in Next.js App Router:
// app/signup/action.ts
"use server";
import { parseWithZod } from "@conform-to/zod";
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "At least 8 characters"),
name: z.string().min(1, "Name is required"),
});
export async function signup(prevState: unknown, formData: FormData) {
const submission = parseWithZod(formData, { schema: signupSchema });
if (submission.status !== "success") {
return submission.reply();
}
// submission.value is fully typed { email, password, name }
await db.users.create({ data: submission.value });
return submission.reply({ resetForm: true });
}
// app/signup/page.tsx
"use client";
import { useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { useActionState } from "react";
import { signup, signupSchema } from "./action";
export default function SignupPage() {
const [lastResult, action] = useActionState(signup, undefined);
const [form, fields] = useForm({
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema: signupSchema });
},
shouldValidate: "onBlur",
shouldRevalidate: "onInput",
});
return (
<form id={form.id} onSubmit={form.onSubmit} action={action} noValidate>
<div>
<label htmlFor={fields.email.id}>Email</label>
<input
id={fields.email.id}
name={fields.email.name}
type="email"
defaultValue={fields.email.initialValue}
aria-invalid={!fields.email.valid || undefined}
aria-describedby={fields.email.errorId}
/>
{fields.email.errors && (
<div id={fields.email.errorId} role="alert">{fields.email.errors}</div>
)}
</div>
<div>
<label htmlFor={fields.password.id}>Password</label>
<input
id={fields.password.id}
name={fields.password.name}
type="password"
defaultValue={fields.password.initialValue}
aria-invalid={!fields.password.valid || undefined}
aria-describedby={fields.password.errorId}
/>
{fields.password.errors && (
<div id={fields.password.errorId} role="alert">{fields.password.errors}</div>
)}
</div>
<div>
<label htmlFor={fields.name.id}>Name</label>
<input
id={fields.name.id}
name={fields.name.name}
defaultValue={fields.name.initialValue}
aria-invalid={!fields.name.valid || undefined}
aria-describedby={fields.name.errorId}
/>
{fields.name.errors && (
<div id={fields.name.errorId} role="alert">{fields.name.errors}</div>
)}
</div>
<button type="submit">Sign Up</button>
</form>
);
}
Key Techniques
Nested Objects
Conform supports nested data via dot-notation names, using getFieldsetProps:
import { useForm, getInputProps, getFieldsetProps } from "@conform-to/react";
const schema = z.object({
name: z.string().min(1),
address: z.object({
street: z.string().min(1),
city: z.string().min(1),
state: z.string().length(2),
zip: z.string().regex(/^\d{5}$/),
}),
});
export function AddressForm() {
const [lastResult, action] = useActionState(saveAddress, undefined);
const [form, fields] = useForm({
lastResult,
onValidate: ({ formData }) => parseWithZod(formData, { schema }),
shouldValidate: "onBlur",
});
const address = fields.address.getFieldset();
return (
<form id={form.id} onSubmit={form.onSubmit} action={action} noValidate>
<input name={fields.name.name} defaultValue={fields.name.initialValue} />
<fieldset>
<legend>Address</legend>
<input name={address.street.name} defaultValue={address.street.initialValue} />
{address.street.errors && <span>{address.street.errors}</span>}
<input name={address.city.name} defaultValue={address.city.initialValue} />
<input name={address.state.name} defaultValue={address.state.initialValue} />
<input name={address.zip.name} defaultValue={address.zip.initialValue} />
</fieldset>
<button type="submit">Save</button>
</form>
);
}
Dynamic Arrays with useFieldList
import { useForm, useFieldList, insert, remove } from "@conform-to/react";
const schema = z.object({
title: z.string().min(1),
items: z.array(
z.object({
description: z.string().min(1),
quantity: z.number().int().positive(),
})
).min(1, "Add at least one item"),
});
export function InvoiceForm() {
const [lastResult, action] = useActionState(createInvoice, undefined);
const [form, fields] = useForm({
lastResult,
onValidate: ({ formData }) => parseWithZod(formData, { schema }),
shouldValidate: "onBlur",
});
const items = fields.items.getFieldList();
return (
<form id={form.id} onSubmit={form.onSubmit} action={action} noValidate>
<input name={fields.title.name} defaultValue={fields.title.initialValue} />
{items.map((item, index) => {
const itemFields = item.getFieldset();
return (
<div key={item.key}>
<input
name={itemFields.description.name}
defaultValue={itemFields.description.initialValue}
placeholder="Description"
/>
<input
name={itemFields.quantity.name}
defaultValue={itemFields.quantity.initialValue}
type="number"
placeholder="Qty"
/>
<button
{...form.remove.getButtonProps({ name: fields.items.name, index })}
>
Remove
</button>
</div>
);
})}
<button
{...form.insert.getButtonProps({
name: fields.items.name,
defaultValue: { description: "", quantity: 1 },
})}
>
Add Item
</button>
<button type="submit">Submit</button>
</form>
);
}
Intent Buttons
Intent buttons let a single form handle multiple actions:
export function TodoForm() {
const [form, fields] = useForm({ /* ... */ });
return (
<form id={form.id} onSubmit={form.onSubmit} action={action} noValidate>
<input name={fields.title.name} />
{/* Different submit buttons with different intents */}
<button type="submit" name="intent" value="save-draft">
Save Draft
</button>
<button type="submit" name="intent" value="publish">
Publish
</button>
</form>
);
}
// In the server action:
export async function handleTodo(prevState: unknown, formData: FormData) {
const intent = formData.get("intent");
const submission = parseWithZod(formData, {
schema: intent === "save-draft" ? draftSchema : publishSchema,
});
if (submission.status !== "success") {
return submission.reply();
}
if (intent === "publish") {
await publishTodo(submission.value);
} else {
await saveDraft(submission.value);
}
return submission.reply({ resetForm: true });
}
Accessibility Helpers
Conform generates stable, unique IDs for id, name, aria-describedby, and aria-invalid automatically:
const [form, fields] = useForm({ /* config */ });
// fields.email provides:
// - fields.email.id → unique id for the input
// - fields.email.name → form field name
// - fields.email.errorId → id for the error element (for aria-describedby)
// - fields.email.valid → boolean for aria-invalid
// - fields.email.errors → string[] of error messages
<input
id={fields.email.id}
name={fields.email.name}
aria-invalid={!fields.email.valid || undefined}
aria-describedby={fields.email.errorId}
/>
<div id={fields.email.errorId} role="alert">
{fields.email.errors?.join(", ")}
</div>
Best Practices
- Share the Zod schema between the server action and client
onValidate— this guarantees identical validation rules on both sides. - Always include
form.idon the form element — Conform uses it to associate fields and manage state correctly. - Use
defaultValueinstead ofvalue— Conform works with uncontrolled inputs. Usingvaluerequires manualonChangehandling and breaks progressive enhancement. - Set
shouldValidate: "onBlur"andshouldRevalidate: "onInput"for the best UX — users see errors after leaving a field, and errors disappear immediately when corrected. - Use
submission.reply()consistently — it serializes the form state (values, errors) in a format thatuseFormcan consume vialastResult. - Add
noValidateto the form element to suppress native browser validation when JS is active, while preserving it as a fallback when JS fails to load. - Prefer intent buttons over separate forms when a single form can serve multiple purposes (draft vs. publish, delete vs. update).
Anti-Patterns
- Using
useStateto manage field values — this defeats Conform's progressive enhancement model. Let native form data flow throughFormData. - Calling
e.preventDefault()manually — useform.onSubmitwhich handles this for you and integrates with the validation lifecycle. - Ignoring
lastResult— without passing the server's reply back touseForm, server-side errors will not display on the client. - Skipping
aria-invalidandaria-describedby— Conform provides these values for free; omitting them makes forms inaccessible to screen readers. - Wrapping Conform fields in controlled components — libraries like MUI require extra work with Conform; prefer native HTML inputs or use the
getInputPropshelpers to bridge the gap. - Using Conform for forms that never submit to the server — purely client-side interactive UIs (calculators, filters) do not benefit from progressive enhancement. Use React Hook Form or local state instead.
Install this skill directly: skilldb add forms-validation-skills
Related Skills
Formik
Formik — useFormik, Field, Form, validation with Yup, FieldArray, custom inputs, submission handling, and error display patterns
React Final Form
React Final Form — subscription-based form state management with fine-grained re-renders, field-level validation, decorator support, and zero dependencies
React Hook Form
React Hook Form — useForm, register, Controller, validation, nested fields, arrays (useFieldArray), DevTools, and performance optimization patterns
Valibot
Valibot — modular schema validation, tiny bundle size, parse/safeParse, pipe transformations, type inference, and comparison with Zod
Vest
Vest — unit-test-inspired form validation framework with suite/test syntax, async rules, group support, warning-level validations, and framework-agnostic design
Yup
Yup — schema-based object validation with casting, transforms, conditional rules, localized error messages, and deep Formik integration