Fix: Conform Not Working — Form Validation Not Triggering, Server Errors Missing, or Zod Schema Rejected
Part of: React & Frontend Errors
Quick Answer
How to fix Conform form validation issues — useForm setup with Zod, server action integration, nested and array fields, file uploads, progressive enhancement, and Remix and Next.js usage.
The Problem
Form submission doesn’t validate and errors don’t appear:
import { useForm } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
const schema = z.object({ name: z.string().min(1) });
function MyForm() {
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
return (
<form id={form.id}>
<input name={fields.name.name} />
<p>{fields.name.errors}</p> {/* Always empty */}
<button>Submit</button>
</form>
);
}Or server-side validation runs but errors don’t show in the UI:
// Server action returns errors but the form doesn't display themOr nested object fields don’t map to the right inputs:
const schema = z.object({
address: z.object({
street: z.string(),
city: z.string(),
}),
});
// Input names don't match the schemaWhy This Happens
Conform is a progressive enhancement form library that works with standard HTML form submissions and Server Actions. It differs from other form libraries in key ways:
- Forms must have
id={form.id}and useform.onSubmit— Conform tracks forms by ID. Without theidprop, it can’t associate the form element with its state. WithoutonSubmit(oraction), client-side validation doesn’t trigger on submission. - Field names use dot notation for nesting —
address.streetmaps toz.object({ address: z.object({ street: z.string() }) }). If inputnameattributes don’t match the schema’s structure, validation passes but parsed data is wrong. - Server errors must be returned in Conform’s format — the server action must return the result of
parseWithZod()orsubmission.reply(). Returning a plain error object doesn’t populatefields.*.errors. lastResultconnects server responses to the form — without passing the server action’s return value touseForm({ lastResult }), server-side validation errors are lost on the client.
The reason these failures feel mysterious is that Conform was built around a philosophy that’s nearly the opposite of React Hook Form’s. RHF treats the form as JavaScript state — controlled inputs, mounted refs, hooks managing every field. Conform treats the form as plain HTML that also has progressive enhancement on top. The browser’s native form submission has to keep working without JavaScript, so Conform encodes state into hidden inputs and intent fields. If you bypass the helpers (getFormProps, getInputProps), you skip the hidden-state plumbing and the library can’t reconcile what the user submitted with what the schema expects. The official guidance is “always spread the helpers.” Many bugs come from treating Conform like RHF and skipping them.
The second source of confusion is that Conform’s relationship with Zod has changed across versions. Early releases coupled tightly to Zod; later releases generalized to “validator adapters” (@conform-to/zod, @conform-to/valibot, @conform-to/yup). If you copy-paste a 0.7-era example into a 1.x project, the API names line up but the import paths and adapter functions changed. See Fix: Valibot Not Working and Fix: Zod Validation Not Working for the validator-side gotchas, and Fix: React Hook Form Not Working for the alternative approach.
Version History: Conform from 0.7 to 1.x
Conform’s API stabilized later than most form libraries because it was rebuilt around React 19’s useActionState. The major releases:
- 0.7.x (mid 2023) — pre-Server Actions era. The hook was called
useFormand returned[form, { fieldA, fieldB }]as an object. Validation adapters lived under different names. Tutorials from this era often useconform.input()helpers that no longer exist. - 0.8.x (late 2023) — added Remix-style
useFetcherintegration and thegetFormProps/getInputPropshelpers. This is when the now-standard “spread the props” pattern emerged. - 0.9.x (Feb 2024) — stabilized the schema adapter approach.
parseWithZodreplaced earlierparsehelpers;@conform-to/zodbecame the canonical Zod integration. Also introducedformData.getAll()correctness for array fields. - 1.0.0 (Aug 2024) — stable release. Locked the
useFormreturn shape, finalized theIntentAPI forinsert/remove/reorder, and aligned the file-upload story with React 19’s nativeFormDatahandling. This is the version targeted by the current docs. - 1.1.x – 1.2.x (late 2024) — added Zod 4 compatibility, improved TypeScript inference for nested arrays, and refined
getInputPropsto setdefaultValuecorrectly for radio groups (a long-standing issue). - 1.3.x (2025) — performance work on large forms with many array fields, smaller bundle (~3KB gzipped for
@conform-to/reactalone), and Valibot adapter parity with the Zod one.
The competitive context matters. React Hook Form (since 2019) is the dominant React forms library and uses uncontrolled inputs with ref-based subscriptions — extremely fast, but the validation story leans on resolvers like @hookform/resolvers/zod. TanStack Form (1.0 in 2024) takes a third path: fully controlled, signal-like field state, framework-agnostic core. Formik (2018) and react-final-form (2018) are legacy now and rarely chosen for new projects. Conform’s unique angle is progressive enhancement plus Server Actions — if you’re shipping a Next.js or Remix app with 'use server' actions, Conform is the only form library designed for that flow from the ground up. See Fix: React Hook Form Not Working and Fix: TanStack Form Not Working for direct comparisons.
Fix 1: Basic Form with Client Validation
npm install @conform-to/react @conform-to/zod zod'use client';
import { useForm, getFormProps, getInputProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
const schema = z.object({
name: z.string({ required_error: 'Name is required' }).min(2, 'At least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number({ required_error: 'Age is required' }).min(18, 'Must be 18+'),
bio: z.string().max(500, 'Max 500 characters').optional(),
});
function SignUpForm() {
const [form, fields] = useForm({
// Client-side validation
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
// Validate on blur (not just submit)
shouldValidate: 'onBlur',
shouldRevalidate: 'onInput',
});
return (
<form {...getFormProps(form)}>
{/* Display form-level errors */}
{form.errors && <div className="text-red-500">{form.errors}</div>}
<div>
<label htmlFor={fields.name.id}>Name</label>
<input {...getInputProps(fields.name, { type: 'text' })} />
{fields.name.errors && (
<p className="text-red-500 text-sm">{fields.name.errors}</p>
)}
</div>
<div>
<label htmlFor={fields.email.id}>Email</label>
<input {...getInputProps(fields.email, { type: 'email' })} />
{fields.email.errors && (
<p className="text-red-500 text-sm">{fields.email.errors}</p>
)}
</div>
<div>
<label htmlFor={fields.age.id}>Age</label>
<input {...getInputProps(fields.age, { type: 'number' })} />
{fields.age.errors && (
<p className="text-red-500 text-sm">{fields.age.errors}</p>
)}
</div>
<div>
<label htmlFor={fields.bio.id}>Bio</label>
<textarea {...getInputProps(fields.bio, { type: 'text' })} />
{fields.bio.errors && (
<p className="text-red-500 text-sm">{fields.bio.errors}</p>
)}
</div>
<button type="submit">Sign Up</button>
</form>
);
}Fix 2: Server Action Integration (Next.js)
// app/actions.ts
'use server';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
export async function createAccount(prevState: unknown, formData: FormData) {
const submission = parseWithZod(formData, { schema });
// Validation failed — return errors to the client
if (submission.status !== 'success') {
return submission.reply();
}
// Validation passed — process the data
const { name, email, password } = submission.value;
try {
await db.insert(users).values({
name,
email,
passwordHash: await hash(password),
});
} catch (error) {
// Return server error to the form
if (isDuplicateEmail(error)) {
return submission.reply({
fieldErrors: {
email: ['This email is already registered'],
},
});
}
return submission.reply({
formErrors: ['Something went wrong. Please try again.'],
});
}
redirect('/dashboard');
}// app/signup/page.tsx
'use client';
import { useForm, getFormProps, getInputProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { useActionState } from 'react';
import { createAccount } from '../actions';
import { schema } from './schema';
export default function SignUpPage() {
const [lastResult, action] = useActionState(createAccount, undefined);
const [form, fields] = useForm({
// Connect server action response
lastResult,
// Client-side validation (same schema as server)
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
shouldValidate: 'onBlur',
shouldRevalidate: 'onInput',
});
return (
<form {...getFormProps(form)} action={action}>
{form.errors?.map((error, i) => (
<div key={i} className="bg-red-50 text-red-700 p-3 rounded">{error}</div>
))}
<div>
<label htmlFor={fields.name.id}>Name</label>
<input {...getInputProps(fields.name, { type: 'text' })} />
<p className="text-red-500">{fields.name.errors}</p>
</div>
<div>
<label htmlFor={fields.email.id}>Email</label>
<input {...getInputProps(fields.email, { type: 'email' })} />
<p className="text-red-500">{fields.email.errors}</p>
</div>
<div>
<label htmlFor={fields.password.id}>Password</label>
<input {...getInputProps(fields.password, { type: 'password' })} />
<p className="text-red-500">{fields.password.errors}</p>
</div>
<div>
<label htmlFor={fields.confirmPassword.id}>Confirm Password</label>
<input {...getInputProps(fields.confirmPassword, { type: 'password' })} />
<p className="text-red-500">{fields.confirmPassword.errors}</p>
</div>
<button type="submit">Create Account</button>
</form>
);
}Fix 3: Nested Objects and Arrays
import { useForm, getFormProps, getInputProps, getFieldsetProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(1),
address: z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
zip: z.string().regex(/^\d{5}$/, 'Must be 5 digits'),
}),
// Array of items
items: z.array(z.object({
product: z.string().min(1),
quantity: z.number().min(1),
})).min(1, 'Add at least one item'),
});
function OrderForm() {
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
// Access nested fields
const address = fields.address.getFieldset();
const items = fields.items.getFieldList();
return (
<form {...getFormProps(form)}>
<input {...getInputProps(fields.name, { type: 'text' })} />
{/* Nested object — fields use dot notation automatically */}
<fieldset {...getFieldsetProps(fields.address)}>
<legend>Address</legend>
<input {...getInputProps(address.street, { type: 'text' })} placeholder="Street" />
<p>{address.street.errors}</p>
<input {...getInputProps(address.city, { type: 'text' })} placeholder="City" />
<p>{address.city.errors}</p>
<input {...getInputProps(address.zip, { type: 'text' })} placeholder="ZIP" />
<p>{address.zip.errors}</p>
</fieldset>
{/* Array fields */}
<div>
<h3>Items</h3>
{items.map((item, index) => {
const itemFields = item.getFieldset();
return (
<div key={item.key}>
<input {...getInputProps(itemFields.product, { type: 'text' })} placeholder="Product" />
<input {...getInputProps(itemFields.quantity, { type: 'number' })} placeholder="Qty" />
{/* Remove button */}
<button
{...form.remove.getButtonProps({ name: fields.items.name, index })}
>
Remove
</button>
</div>
);
})}
{/* Add button */}
<button
{...form.insert.getButtonProps({
name: fields.items.name,
defaultValue: { product: '', quantity: 1 },
})}
>
Add Item
</button>
<p>{fields.items.errors}</p>
</div>
<button type="submit">Submit Order</button>
</form>
);
}Fix 4: File Uploads
const schema = z.object({
name: z.string().min(1),
avatar: z
.instanceof(File)
.refine((file) => file.size <= 5 * 1024 * 1024, 'Max file size is 5MB')
.refine(
(file) => ['image/jpeg', 'image/png', 'image/webp'].includes(file.type),
'Only JPEG, PNG, and WebP are allowed'
),
documents: z
.array(z.instanceof(File))
.min(1, 'Upload at least one document')
.max(5, 'Max 5 documents'),
});
function UploadForm() {
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
return (
<form {...getFormProps(form)} encType="multipart/form-data">
<input {...getInputProps(fields.name, { type: 'text' })} />
<div>
<label>Avatar</label>
<input {...getInputProps(fields.avatar, { type: 'file' })} accept="image/*" />
<p>{fields.avatar.errors}</p>
</div>
<div>
<label>Documents</label>
<input {...getInputProps(fields.documents, { type: 'file' })} multiple />
<p>{fields.documents.errors}</p>
</div>
<button type="submit">Upload</button>
</form>
);
}Fix 5: Integration with UI Libraries
// With shadcn/ui components
import { useForm, getFormProps } from '@conform-to/react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
function ShadcnForm() {
const [form, fields] = useForm({ /* ... */ });
return (
<form {...getFormProps(form)}>
<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.errors}
aria-describedby={fields.name.errors ? `${fields.name.id}-error` : undefined}
/>
{fields.name.errors && (
<p id={`${fields.name.id}-error`} className="text-sm text-red-500">
{fields.name.errors}
</p>
)}
</div>
{/* Select — uses hidden input for form data */}
<div>
<Label>Role</Label>
<select name={fields.role.name} defaultValue={fields.role.initialValue}>
<option value="">Select a role</option>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<p className="text-red-500">{fields.role.errors}</p>
</div>
<Button type="submit">Save</Button>
</form>
);
}Fix 6: Remix Integration
// app/routes/signup.tsx — Remix
import { useForm, getFormProps, getInputProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
import { schema } from './schema';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== 'success') {
return json(submission.reply());
}
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',
});
return (
<Form method="post" {...getFormProps(form)}>
<input {...getInputProps(fields.email, { type: 'email' })} />
<p>{fields.email.errors}</p>
<button type="submit">Sign Up</button>
</Form>
);
}Still Not Working?
Errors never appear on submit — check that form.id is on the <form> element and that getFormProps(form) spreads id and onSubmit. Without the ID, Conform can’t match the form to its state. Also verify onValidate returns parseWithZod(formData, { schema }) — not the schema itself.
Server errors show once then disappear — lastResult must persist between renders. With useActionState, the result is managed by React. If you’re managing state manually, make sure the server response is stored in state, not just a local variable.
Number inputs always fail validation — FormData values are always strings. Zod’s z.number() rejects strings. Use z.coerce.number() instead, or add a z.preprocess step: z.preprocess((v) => Number(v), z.number()). Conform’s getInputProps with type: 'number' handles this, but only if the schema expects coercion.
Array field buttons don’t work — form.insert.getButtonProps() and form.remove.getButtonProps() return type: 'submit' with intent data. If JavaScript is disabled, these work via form submission. If JavaScript is enabled, they work via Conform’s client-side logic. Make sure the buttons are inside the <form> element.
Upgraded to Zod 4 and parseWithZod types broke — Zod 4 changed several inference signatures, especially z.record and z.discriminatedUnion. Upgrade @conform-to/zod to its 1.2+ release at the same time. The adapter package is what owns the bridging types — leaving it on a Zod 3-era version while bumping Zod itself is the most common cause of “Type instantiation is excessively deep” errors here.
Conform v1 migration breaks existing forms — the 0.9 → 1.0 upgrade renamed a few internal helpers and tightened the useForm generic. Replace conform.input(fields.x) with getInputProps(fields.x, { type: '...' }), remove any explicit id={form.id} (it’s now spread by getFormProps), and pass the schema through the adapter — not directly to useForm. The migration guide on the Conform docs site lists every renamed export.
Custom controlled components (e.g., a date picker) don’t sync — Conform expects native form submission to provide the value. Controlled components need a hidden <input> shadow that holds the serialized value, plus defaultValue derived from fields.x.initialValue. The shadcn/ui Select example in Fix 5 demonstrates the pattern: render the visible component, but back it with a plain <select> or <input type="hidden"> whose name matches the field.
For related form issues, see Fix: React Hook Form Not Working, Fix: Zod Validation Not Working, Fix: TanStack Form Not Working, and Fix: Valibot Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: cmdk Not Working — Command Palette Not Opening, Items Not Filtering, or Keyboard Navigation Broken
How to fix cmdk command palette issues — Dialog setup, custom filtering, groups and separators, keyboard shortcuts, async search, nested pages, and integration with shadcn/ui and Tailwind.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Lingui Not Working — Messages Not Extracted, Translations Missing, or Macro Errors
How to fix Lingui.js i18n issues — setup with React, message extraction, macro compilation, ICU format, lazy loading catalogs, and Next.js integration.
Fix: Mantine Not Working — Styles Not Loading, Theme Not Applying, or Components Broken After Upgrade
How to fix Mantine UI issues — MantineProvider setup, PostCSS configuration, theme customization, dark mode, form validation with useForm, and Next.js App Router integration.