Skip to main content
Technology & EngineeringForms Validation325 lines

Conform

Conform — progressive enhancement forms, Server Actions, Zod integration, nested objects, arrays, intent buttons, and accessibility patterns

Quick Summary24 lines
Conform is a progressive enhancement-first form library designed for modern React frameworks like Next.js App Router and Remix. It works by reading form data from the native `FormData` API, not from controlled React state. This means forms function even before JavaScript loads — the server processes the submission, validates the data, and returns errors that Conform maps back to the correct fields. Once JS hydrates, Conform adds client-side validation and enhanced UX without changing the underlying mechanism. This architecture aligns perfectly with React Server Components and Server Actions.

## Key Points

- **Share the Zod schema** between the server action and client `onValidate` — this guarantees identical validation rules on both sides.
- **Always include `form.id` on the form element** — Conform uses it to associate fields and manage state correctly.
- **Use `defaultValue` instead of `value`** — Conform works with uncontrolled inputs. Using `value` requires manual `onChange` handling and breaks progressive enhancement.
- **Set `shouldValidate: "onBlur"` and `shouldRevalidate: "onInput"`** for the best UX — users see errors after leaving a field, and errors disappear immediately when corrected.
- **Use `submission.reply()`** consistently — it serializes the form state (values, errors) in a format that `useForm` can consume via `lastResult`.
- **Add `noValidate`** to the form element to suppress native browser validation when JS is active, while preserving it as a fallback when JS fails to load.
- **Prefer intent buttons over separate forms** when a single form can serve multiple purposes (draft vs. publish, delete vs. update).
- **Using `useState` to manage field values** — this defeats Conform's progressive enhancement model. Let native form data flow through `FormData`.
- **Calling `e.preventDefault()` manually** — use `form.onSubmit` which handles this for you and integrates with the validation lifecycle.
- **Ignoring `lastResult`** — without passing the server's reply back to `useForm`, server-side errors will not display on the client.
- **Skipping `aria-invalid` and `aria-describedby`** — Conform provides these values for free; omitting them makes forms inaccessible to screen readers.
- **Wrapping Conform fields in controlled components** — libraries like MUI require extra work with Conform; prefer native HTML inputs or use the `getInputProps` helpers to bridge the gap.

## Quick Example

```typescript
npm install @conform-to/react @conform-to/zod zod
```
skilldb get forms-validation-skills/ConformFull skill: 325 lines
Paste into your CLAUDE.md or agent config

Conform

Core Philosophy

Conform is a progressive enhancement-first form library designed for modern React frameworks like Next.js App Router and Remix. It works by reading form data from the native FormData API, not from controlled React state. This means forms function even before JavaScript loads — the server processes the submission, validates the data, and returns errors that Conform maps back to the correct fields. Once JS hydrates, Conform adds client-side validation and enhanced UX without changing the underlying mechanism. This architecture aligns perfectly with React Server Components and Server Actions.

Setup

npm install @conform-to/react @conform-to/zod zod

Basic Server Action form in Next.js App Router:

// app/signup/action.ts
"use server";

import { parseWithZod } from "@conform-to/zod";
import { z } from "zod";

export const signupSchema = z.object({
  email: z.string().email("Invalid email"),
  password: z.string().min(8, "At least 8 characters"),
  name: z.string().min(1, "Name is required"),
});

export async function signup(prevState: unknown, formData: FormData) {
  const submission = parseWithZod(formData, { schema: signupSchema });

  if (submission.status !== "success") {
    return submission.reply();
  }

  // submission.value is fully typed { email, password, name }
  await db.users.create({ data: submission.value });

  return submission.reply({ resetForm: true });
}
// app/signup/page.tsx
"use client";

import { useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { useActionState } from "react";
import { signup, signupSchema } from "./action";

export default function SignupPage() {
  const [lastResult, action] = useActionState(signup, undefined);

  const [form, fields] = useForm({
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: signupSchema });
    },
    shouldValidate: "onBlur",
    shouldRevalidate: "onInput",
  });

  return (
    <form id={form.id} onSubmit={form.onSubmit} action={action} noValidate>
      <div>
        <label htmlFor={fields.email.id}>Email</label>
        <input
          id={fields.email.id}
          name={fields.email.name}
          type="email"
          defaultValue={fields.email.initialValue}
          aria-invalid={!fields.email.valid || undefined}
          aria-describedby={fields.email.errorId}
        />
        {fields.email.errors && (
          <div id={fields.email.errorId} role="alert">{fields.email.errors}</div>
        )}
      </div>

      <div>
        <label htmlFor={fields.password.id}>Password</label>
        <input
          id={fields.password.id}
          name={fields.password.name}
          type="password"
          defaultValue={fields.password.initialValue}
          aria-invalid={!fields.password.valid || undefined}
          aria-describedby={fields.password.errorId}
        />
        {fields.password.errors && (
          <div id={fields.password.errorId} role="alert">{fields.password.errors}</div>
        )}
      </div>

      <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.valid || undefined}
          aria-describedby={fields.name.errorId}
        />
        {fields.name.errors && (
          <div id={fields.name.errorId} role="alert">{fields.name.errors}</div>
        )}
      </div>

      <button type="submit">Sign Up</button>
    </form>
  );
}

Key Techniques

Nested Objects

Conform supports nested data via dot-notation names, using getFieldsetProps:

import { useForm, getInputProps, getFieldsetProps } from "@conform-to/react";

const schema = z.object({
  name: z.string().min(1),
  address: z.object({
    street: z.string().min(1),
    city: z.string().min(1),
    state: z.string().length(2),
    zip: z.string().regex(/^\d{5}$/),
  }),
});

export function AddressForm() {
  const [lastResult, action] = useActionState(saveAddress, undefined);
  const [form, fields] = useForm({
    lastResult,
    onValidate: ({ formData }) => parseWithZod(formData, { schema }),
    shouldValidate: "onBlur",
  });

  const address = fields.address.getFieldset();

  return (
    <form id={form.id} onSubmit={form.onSubmit} action={action} noValidate>
      <input name={fields.name.name} defaultValue={fields.name.initialValue} />

      <fieldset>
        <legend>Address</legend>
        <input name={address.street.name} defaultValue={address.street.initialValue} />
        {address.street.errors && <span>{address.street.errors}</span>}

        <input name={address.city.name} defaultValue={address.city.initialValue} />
        <input name={address.state.name} defaultValue={address.state.initialValue} />
        <input name={address.zip.name} defaultValue={address.zip.initialValue} />
      </fieldset>

      <button type="submit">Save</button>
    </form>
  );
}

Dynamic Arrays with useFieldList

import { useForm, useFieldList, insert, remove } from "@conform-to/react";

const schema = z.object({
  title: z.string().min(1),
  items: z.array(
    z.object({
      description: z.string().min(1),
      quantity: z.number().int().positive(),
    })
  ).min(1, "Add at least one item"),
});

export function InvoiceForm() {
  const [lastResult, action] = useActionState(createInvoice, undefined);
  const [form, fields] = useForm({
    lastResult,
    onValidate: ({ formData }) => parseWithZod(formData, { schema }),
    shouldValidate: "onBlur",
  });

  const items = fields.items.getFieldList();

  return (
    <form id={form.id} onSubmit={form.onSubmit} action={action} noValidate>
      <input name={fields.title.name} defaultValue={fields.title.initialValue} />

      {items.map((item, index) => {
        const itemFields = item.getFieldset();
        return (
          <div key={item.key}>
            <input
              name={itemFields.description.name}
              defaultValue={itemFields.description.initialValue}
              placeholder="Description"
            />
            <input
              name={itemFields.quantity.name}
              defaultValue={itemFields.quantity.initialValue}
              type="number"
              placeholder="Qty"
            />
            <button
              {...form.remove.getButtonProps({ name: fields.items.name, index })}
            >
              Remove
            </button>
          </div>
        );
      })}

      <button
        {...form.insert.getButtonProps({
          name: fields.items.name,
          defaultValue: { description: "", quantity: 1 },
        })}
      >
        Add Item
      </button>

      <button type="submit">Submit</button>
    </form>
  );
}

Intent Buttons

Intent buttons let a single form handle multiple actions:

export function TodoForm() {
  const [form, fields] = useForm({ /* ... */ });

  return (
    <form id={form.id} onSubmit={form.onSubmit} action={action} noValidate>
      <input name={fields.title.name} />

      {/* Different submit buttons with different intents */}
      <button type="submit" name="intent" value="save-draft">
        Save Draft
      </button>
      <button type="submit" name="intent" value="publish">
        Publish
      </button>
    </form>
  );
}

// In the server action:
export async function handleTodo(prevState: unknown, formData: FormData) {
  const intent = formData.get("intent");

  const submission = parseWithZod(formData, {
    schema: intent === "save-draft" ? draftSchema : publishSchema,
  });

  if (submission.status !== "success") {
    return submission.reply();
  }

  if (intent === "publish") {
    await publishTodo(submission.value);
  } else {
    await saveDraft(submission.value);
  }

  return submission.reply({ resetForm: true });
}

Accessibility Helpers

Conform generates stable, unique IDs for id, name, aria-describedby, and aria-invalid automatically:

const [form, fields] = useForm({ /* config */ });

// fields.email provides:
// - fields.email.id         → unique id for the input
// - fields.email.name       → form field name
// - fields.email.errorId    → id for the error element (for aria-describedby)
// - fields.email.valid      → boolean for aria-invalid
// - fields.email.errors     → string[] of error messages

<input
  id={fields.email.id}
  name={fields.email.name}
  aria-invalid={!fields.email.valid || undefined}
  aria-describedby={fields.email.errorId}
/>
<div id={fields.email.errorId} role="alert">
  {fields.email.errors?.join(", ")}
</div>

Best Practices

  • Share the Zod schema between the server action and client onValidate — this guarantees identical validation rules on both sides.
  • Always include form.id on the form element — Conform uses it to associate fields and manage state correctly.
  • Use defaultValue instead of value — Conform works with uncontrolled inputs. Using value requires manual onChange handling and breaks progressive enhancement.
  • Set shouldValidate: "onBlur" and shouldRevalidate: "onInput" for the best UX — users see errors after leaving a field, and errors disappear immediately when corrected.
  • Use submission.reply() consistently — it serializes the form state (values, errors) in a format that useForm can consume via lastResult.
  • Add noValidate to the form element to suppress native browser validation when JS is active, while preserving it as a fallback when JS fails to load.
  • Prefer intent buttons over separate forms when a single form can serve multiple purposes (draft vs. publish, delete vs. update).

Anti-Patterns

  • Using useState to manage field values — this defeats Conform's progressive enhancement model. Let native form data flow through FormData.
  • Calling e.preventDefault() manually — use form.onSubmit which handles this for you and integrates with the validation lifecycle.
  • Ignoring lastResult — without passing the server's reply back to useForm, server-side errors will not display on the client.
  • Skipping aria-invalid and aria-describedby — Conform provides these values for free; omitting them makes forms inaccessible to screen readers.
  • Wrapping Conform fields in controlled components — libraries like MUI require extra work with Conform; prefer native HTML inputs or use the getInputProps helpers to bridge the gap.
  • Using Conform for forms that never submit to the server — purely client-side interactive UIs (calculators, filters) do not benefit from progressive enhancement. Use React Hook Form or local state instead.

Install this skill directly: skilldb add forms-validation-skills

Get CLI access →