Skip to content

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

FixDevs ·

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.

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.

For related form issues, see Fix: React Hook Form Not Working and Fix: Zod Validation 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