conform
Builds progressive enhancement forms with Conform using web standards, server validation, and framework integration. Use when building Remix/Next.js forms, implementing progressive enhancement, or needing accessible form validation.
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
Conform
Type-safe form validation library with progressive enhancement for Remix and Next.js.
Quick Start
npm install @conform-to/react @conform-to/zod zod
import { useForm } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export default function LoginForm() {
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
return (
<form id={form.id} onSubmit={form.onSubmit}>
<input
name={fields.email.name}
defaultValue={fields.email.initialValue}
/>
<div>{fields.email.errors}</div>
<input
name={fields.password.name}
type="password"
defaultValue={fields.password.initialValue}
/>
<div>{fields.password.errors}</div>
<button>Submit</button>
</form>
);
}
Remix Integration
Action & Loader
import { json, redirect } from '@remix-run/node';
import { useActionData } from '@remix-run/react';
import { useForm } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'At least 8 characters'),
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== 'success') {
return json(submission.reply());
}
// Process valid data
await createUser(submission.value);
return redirect('/dashboard');
}
export default function SignUp() {
const lastResult = useActionData<typeof action>();
const [form, fields] = useForm({
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
shouldValidate: 'onBlur',
shouldRevalidate: 'onInput',
});
return (
<form method="post" id={form.id} onSubmit={form.onSubmit}>
<label>
Email
<input name={fields.email.name} />
<div>{fields.email.errors}</div>
</label>
<label>
Password
<input name={fields.password.name} type="password" />
<div>{fields.password.errors}</div>
</label>
<button>Sign Up</button>
</form>
);
}
Next.js Integration (App Router)
Server Action
'use server';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export async function login(prevState: unknown, formData: FormData) {
const submission = parseWithZod(formData, { schema });
if (submission.status !== 'success') {
return submission.reply();
}
// Authenticate user
const user = await authenticate(submission.value);
if (!user) {
return submission.reply({
formErrors: ['Invalid credentials'],
});
}
redirect('/dashboard');
}
Client Component
'use client';
import { useFormState } from 'react-dom';
import { useForm } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { login } from './actions';
export function LoginForm() {
const [lastResult, action] = useFormState(login, undefined);
const [form, fields] = useForm({
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
return (
<form id={form.id} action={action} onSubmit={form.onSubmit}>
<input name={fields.email.name} />
<div>{fields.email.errors}</div>
<input name={fields.password.name} type="password" />
<div>{fields.password.errors}</div>
<button>Login</button>
</form>
);
}
Form Configuration
useForm Options
const [form, fields] = useForm({
// Last server result
lastResult,
// Client-side validation
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
// When to validate
shouldValidate: 'onBlur', // 'onSubmit' | 'onBlur' | 'onInput'
shouldRevalidate: 'onInput', // When to re-validate after error
// Initial values
defaultValue: {
email: '',
password: '',
},
// Constraint validation (HTML5)
constraint: getZodConstraint(schema),
});
Form Props
<form
id={form.id}
onSubmit={form.onSubmit}
noValidate // Disable browser validation
>
{/* Form errors */}
{form.errors && <div>{form.errors}</div>}
</form>
Field Helpers
getInputProps
import { getInputProps } from '@conform-to/react';
<input
{...getInputProps(fields.email, {
type: 'email',
ariaAttributes: true,
})}
/>
// Generates:
// name, id, defaultValue, aria-invalid, aria-describedby
getTextareaProps
import { getTextareaProps } from '@conform-to/react';
<textarea {...getTextareaProps(fields.message)} />
getSelectProps
import { getSelectProps } from '@conform-to/react';
<select {...getSelectProps(fields.country)}>
<option value="">Select country</option>
<option value="us">United States</option>
</select>
Checkbox/Radio
import { getInputProps } from '@conform-to/react';
<input
{...getInputProps(fields.remember, { type: 'checkbox' })}
/>
<input
{...getInputProps(fields.plan, { type: 'radio', value: 'free' })}
/>
<input
{...getInputProps(fields.plan, { type: 'radio', value: 'pro' })}
/>
Nested Objects
const schema = z.object({
user: z.object({
name: z.string(),
email: z.string().email(),
}),
address: z.object({
street: z.string(),
city: z.string(),
}),
});
function Form() {
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
const user = fields.user.getFieldset();
const address = fields.address.getFieldset();
return (
<form>
<input name={user.name.name} />
<input name={user.email.name} />
<input name={address.street.name} />
<input name={address.city.name} />
</form>
);
}
Field Arrays
import { useForm, useFieldList, insert, remove } from '@conform-to/react';
const schema = z.object({
tasks: z.array(z.object({
title: z.string(),
done: z.boolean().default(false),
})),
});
function TaskForm() {
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
defaultValue: {
tasks: [{ title: '', done: false }],
},
});
const tasks = fields.tasks.getFieldList();
return (
<form id={form.id} onSubmit={form.onSubmit}>
{tasks.map((task, index) => {
const taskFields = task.getFieldset();
return (
<div key={task.key}>
<input name={taskFields.title.name} />
<input
type="checkbox"
name={taskFields.done.name}
value="true"
/>
<button
{...form.remove.getButtonProps({
name: fields.tasks.name,
index,
})}
>
Remove
</button>
</div>
);
})}
<button
{...form.insert.getButtonProps({
name: fields.tasks.name,
defaultValue: { title: '', done: false },
})}
>
Add Task
</button>
<button>Submit</button>
</form>
);
}
Intent Buttons
Handle different submit actions:
<form>
<button name="intent" value="save">
Save Draft
</button>
<button name="intent" value="publish">
Publish
</button>
</form>
// In action
export async function action({ request }) {
const formData = await request.formData();
const intent = formData.get('intent');
if (intent === 'save') {
// Save draft
} else if (intent === 'publish') {
// Publish
}
}
File Uploads
const schema = z.object({
avatar: z.instanceof(File)
.refine((file) => file.size < 5_000_000, 'Max 5MB'),
});
function AvatarForm() {
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
return (
<form method="post" encType="multipart/form-data">
<input type="file" name={fields.avatar.name} accept="image/*" />
<div>{fields.avatar.errors}</div>
</form>
);
}
Accessibility
Conform automatically handles accessibility:
// Using getInputProps with ariaAttributes
<input
{...getInputProps(fields.email, {
type: 'email',
ariaAttributes: true,
})}
/>
// Generates:
// aria-invalid="true" when has errors
// aria-describedby pointing to error element
// Error element
<div id={fields.email.errorId}>{fields.email.errors}</div>
Validation Schemas
With Zod
import { parseWithZod, getZodConstraint } from '@conform-to/zod';
const [form, fields] = useForm({
constraint: getZodConstraint(schema),
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
With Yup
import { parseWithYup, getYupConstraint } from '@conform-to/yup';
const [form, fields] = useForm({
constraint: getYupConstraint(schema),
onValidate({ formData }) {
return parseWithYup(formData, { schema });
},
});
With Valibot
import { parseWithValibot, getValibotConstraint } from '@conform-to/valibot';
const [form, fields] = useForm({
constraint: getValibotConstraint(schema),
onValidate({ formData }) {
return parseWithValibot(formData, { schema });
},
});
Error Handling
Field Errors
{fields.email.errors?.map((error, i) => (
<div key={i} className="error">{error}</div>
))}
Form Errors
{form.errors?.map((error, i) => (
<div key={i} className="form-error">{error}</div>
))}
Custom Server Errors
// In action
return submission.reply({
fieldErrors: {
email: ['Email already registered'],
},
formErrors: ['Something went wrong'],
});
See references/api.md for complete API reference.
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-01-20
- Index updated
- 2026-01-28
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!