Fix: Conform Not Working — Form Validation Not Triggering, Server Errors Missing, or Zod Schema Rejected
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.
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.
For related form issues, see Fix: React Hook Form Not Working and Fix: Zod Validation 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.