Skip to content

Fix: Conform Not Working — Form Validation Not Triggering, Server Errors Missing, or Zod Schema Rejected

FixDevs · (Updated: )

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 them

Or 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 schema

Why 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 use form.onSubmit — Conform tracks forms by ID. Without the id prop, it can’t associate the form element with its state. Without onSubmit (or action), client-side validation doesn’t trigger on submission.
  • Field names use dot notation for nestingaddress.street maps to z.object({ address: z.object({ street: z.string() }) }). If input name attributes 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() or submission.reply(). Returning a plain error object doesn’t populate fields.*.errors.
  • lastResult connects server responses to the form — without passing the server action’s return value to useForm({ 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 useForm and returned [form, { fieldA, fieldB }] as an object. Validation adapters lived under different names. Tutorials from this era often use conform.input() helpers that no longer exist.
  • 0.8.x (late 2023) — added Remix-style useFetcher integration and the getFormProps / getInputProps helpers. This is when the now-standard “spread the props” pattern emerged.
  • 0.9.x (Feb 2024) — stabilized the schema adapter approach. parseWithZod replaced earlier parse helpers; @conform-to/zod became the canonical Zod integration. Also introduced formData.getAll() correctness for array fields.
  • 1.0.0 (Aug 2024)stable release. Locked the useForm return shape, finalized the Intent API for insert / remove / reorder, and aligned the file-upload story with React 19’s native FormData handling. 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 getInputProps to set defaultValue correctly 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/react alone), 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 disappearlastResult 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 workform.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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles