Skip to content
Back to skills

Conform

ASecurity

Conform is a React form validation library that progressively enhances native HTML forms: it validates FormData against a Zod, Valibot or Yup schema on the server and, optionally, on the client, so a form works before JavaScript loads. Use when a user asks to build a form with Conform, validate a Next.js Server Action or a React Router / Remix action, share one schema between client and server, add or remove rows in a field list, or move to Conform's future API.

  • 142 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added May 27, 2026
developmentjavascriptgojavabashreactnextjsnodegitapi

Works with

  • terminal
  • cli
  • api

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 2 files and shows the line behind each finding

Scanned October 4, 2026

npx -y skills add TerminalSkills/skills --skill conform --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Conform?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Conform
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/terminalskills-conform/badge)](https://www.skillsdirectory.com/skills/terminalskills-conform)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: conform
description: >-
  Conform is a React form validation library that progressively enhances
  native HTML forms: it validates FormData against a Zod, Valibot or Yup
  schema on the server and, optionally, on the client, so a form works before
  JavaScript loads. Use when a user asks to build a form with Conform,
  validate a Next.js Server Action or a React Router / Remix action, share one
  schema between client and server, add or remove rows in a field list, or
  move to Conform's future API.
license: Apache-2.0
compatibility: 'React 18+. Next.js Server Actions, React Router / Remix, or any server that receives FormData'
metadata:
  author: terminal-skills
  version: 1.1.0
  category: development
  tags: [conform, forms, remix, progressive-enhancement, server-validation]
  repository: https://github.com/edmundhung/conform
---

# Conform

## Overview

Conform is a progressive enhancement form library. The form posts plain `FormData`; the server parses and validates it against a schema and sends the result back; once JavaScript loads, the same schema can validate in the browser. It does not own your markup — any valid HTML form works, and field names, ids and ARIA attributes are derived for you.

Version 1.21 (August 2026) ships two APIs in the same packages. The stable API (`@conform-to/react`, `@conform-to/zod`) is what the guides on conform.guide describe and is covered first below. The future API (`@conform-to/react/future`) is the preview of v2: the official examples already use it, but it is marked experimental and may change in a minor release.

## Instructions

### Install

```bash
npm install @conform-to/react @conform-to/zod zod
npm install @conform-to/react @conform-to/valibot valibot   # Valibot instead of Zod
npm install @conform-to/react @conform-to/yup yup           # Yup instead of Zod
```

The Zod adapter has one entry point per Zod major. `npm install zod` now gives Zod 4, so import from the `v4` subpath; the bare `@conform-to/zod` import is for Zod 3 and fails when Zod 4 is installed with a missing-export error (`The requested module 'zod' does not provide an export named 'ZodBranded'` in Node; bundlers word it differently).

```ts
import { parseWithZod } from '@conform-to/zod/v4'; // Zod 4
import { parseWithZod } from '@conform-to/zod';    // Zod 3
```

### Schema and server action (Next.js)

Keep the schema in its own module: a `'use server'` file may only export async functions, so a schema exported from the action file cannot be imported by the client form.

```ts
// app/contact/schema.ts — shared by the server action and the client form
import { z } from 'zod';

export const inquirySchema = z.object({
  name: z.string({ error: 'Name is required' }).min(2),
  email: z.email('Enter a valid email address'),
  seats: z.number().int().min(1).max(20),
  newsletter: z.boolean().default(false),
  message: z.string({ error: 'Message is required' }).min(10).max(1000),
});
```

```ts
// app/contact/actions.ts
'use server';
import { parseWithZod } from '@conform-to/zod/v4';
import { saveInquiry, seatsLeft } from '@/lib/inquiries';
import { inquirySchema } from './schema';

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

  if (submission.status !== 'success') {
    return submission.reply(); // field errors + submitted values go back to the form
  }
  if (submission.value.seats > (await seatsLeft())) {
    return submission.reply({ fieldErrors: { seats: ['Not enough seats left'] } });
  }

  await saveInquiry(submission.value); // typed: seats is a number, newsletter a boolean
  return submission.reply({ resetForm: true });
}
```

`parseWithZod` coerces form strings before validating: an empty string becomes `undefined`, `z.number()` and `z.bigint()` fields are cast, `z.boolean()` is `true` when the value is `on`, `z.date()` goes through `new Date()`. Pass `disableAutoCoercion: true` to do this yourself. `reply()` also accepts `formErrors` (errors for the whole form) and `hideFields: ['password']` (do not echo a value back to the browser).

### Client form

```tsx
// app/contact/form.tsx
'use client';
import { useActionState } from 'react';
import { getFormProps, getInputProps, getTextareaProps, useForm } from '@conform-to/react';
import { getZodConstraint, parseWithZod } from '@conform-to/zod/v4';
import { submitInquiry } from './actions';
import { inquirySchema } from './schema';

export function InquiryForm() {
  const [lastResult, action, pending] = useActionState(submitInquiry, undefined);
  const [form, fields] = useForm({
    id: 'inquiry', // prefix for generated ids; omit it to get a random one
    lastResult, // server result: errors and values survive a no-JS round trip
    constraint: getZodConstraint(inquirySchema), // required/min/max attributes (Zod 4.6+: see Guidelines)
    shouldValidate: 'onBlur',
    shouldRevalidate: 'onInput',
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: inquirySchema });
    },
  });

  return (
    <form {...getFormProps(form)} action={action}>
      <div id={form.errorId}>{form.errors}</div>

      <label htmlFor={fields.name.id}>Name</label>
      <input {...getInputProps(fields.name, { type: 'text' })} />
      <div id={fields.name.errorId}>{fields.name.errors}</div>

      <label htmlFor={fields.email.id}>Email</label>
      <input {...getInputProps(fields.email, { type: 'email' })} />
      <div id={fields.email.errorId}>{fields.email.errors}</div>

      <label htmlFor={fields.seats.id}>Seats</label>
      <input {...getInputProps(fields.seats, { type: 'number' })} />
      <div id={fields.seats.errorId}>{fields.seats.errors}</div>

      <label htmlFor={fields.newsletter.id}>Send me the newsletter</label>
      <input {...getInputProps(fields.newsletter, { type: 'checkbox' })} />

      <label htmlFor={fields.message.id}>Message</label>
      <textarea {...getTextareaProps(fields.message)} />
      <div id={fields.message.errorId}>{fields.message.errors}</div>

      <button disabled={pending}>Send</button>
    </form>
  );
}
```

- `useForm` returns a tuple, `[form, fields]`. Every option is optional: leave out `onValidate` and validation runs only on the server, including on blur and on input, which keeps the schema out of the client bundle at the cost of a request per check.
- `shouldValidate` takes `'onSubmit'` (default), `'onBlur'` or `'onInput'`; `shouldRevalidate` takes the same values and defaults to whatever `shouldValidate` is.
- `getFormProps`, `getInputProps` (the `type` option is required), `getTextareaProps`, `getSelectProps`, `getFieldsetProps` and `getCollectionProps` set `id`, `name`, `form`, `key`, default value, constraint and ARIA attributes. They are optional — the same values are on the metadata (`fields.email.name`, `.initialValue`, `.errors`, `.errorId`, `.valid`).
- With React 18 or Next.js 14, use `useFormState` from `react-dom` instead of `useActionState`.
- React Router and Remix: read the action's return value with `useActionData()` and pass it as `lastResult`, render `<Form method="post" {...getFormProps(form)}>`, and call `parseWithZod(await request.formData(), { schema })` in the `action`. If the action resets a form whose defaults come from a loader, pass `lastResult: navigation.state === 'idle' ? lastResult : null` so the reset waits for revalidation.

### Nested objects, lists and intents

Field names follow `address.city` and `lines[0].amount`. Call `fields.address.getFieldset()` for an object and `fields.lines.getFieldList()` for an array instead of writing names by hand. Lists change through intent buttons — ordinary submit buttons carrying a reserved name, so they work without JavaScript: `form.insert`, `form.remove`, `form.reorder`, `form.update`, `form.reset` and `form.validate`, each with `.getButtonProps({ name, ... })` or callable directly, e.g. `form.validate({ name: fields.email.name })`. See Example 2.

Files: set `method="POST"` and `encType="multipart/form-data"` on the form and validate with `z.instanceof(File)`. For a multi-file input or a checkbox group, errors attach to each index (`files[0]`), so render `Object.values(fields.files.allErrors).flat()`.

Custom inputs (a select or date picker from a UI library that renders no native input): `const control = useInputControl(fields.color)` gives `control.value`, `control.change(value)`, `control.focus()` and `control.blur()` to wire into the component.

### The future API (v2 preview)

```ts
// app/signup/schema.ts
import { coerceFormValue } from '@conform-to/zod/v4/future';
import { z } from 'zod';

// coerceFormValue strips empty strings and casts numbers, booleans and dates
export const signupSchema = coerceFormValue(
  z.object({ email: z.email(), age: z.number().min(18), terms: z.boolean() }),
);
```

```ts
// app/signup/actions.ts
'use server';
import { parseSubmission, report } from '@conform-to/react/future';
import { isEmailTaken } from '@/lib/accounts';
import { signupSchema } from './schema';

export async function register(prevState: unknown, formData: FormData) {
  const submission = parseSubmission(formData);
  const result = signupSchema.safeParse(submission.payload);

  if (!result.success) {
    return report(submission, { error: { issues: result.error.issues } });
  }
  if (await isEmailTaken(result.data.email)) {
    return report(submission, { error: { fieldErrors: { email: ['Email is already registered'] } } });
  }
  return report(submission, { reset: true });
}
```

```tsx
// in the client component
import { useForm } from '@conform-to/react/future';

const [lastResult, action] = useActionState(register, null);
const { form, fields, intent } = useForm(signupSchema, { lastResult, shouldValidate: 'onBlur' });
// <form {...form.props} action={action}>
//   <input name={fields.email.name} defaultValue={fields.email.defaultValue}
//     aria-invalid={!fields.email.valid || undefined} aria-describedby={fields.email.ariaDescribedBy} />
```

What differs from the stable API: `useForm` takes any Standard Schema as its first argument and returns an object, not a tuple; `form.props` replaces `getFormProps`; fields expose `defaultValue` instead of `initialValue`; `parseSubmission` + `report` replace `parseWithZod` + `reply`; list changes are method calls (`intent.insert({ name })`, `intent.remove({ name, index })`); constraints come from `getConstraints(schema)`. Do not mix the two in one form — a `lastResult` produced by `report()` is not the shape the stable `useForm` expects, and the reverse.

## Examples

### Example 1: Contact form that validates on the server and in the browser

**User request:** "Add a contact form to our Next.js app with Conform. It has to work with JavaScript disabled."

Create the three files from Instructions (`schema.ts`, `actions.ts`, `form.tsx`) and render `<InquiryForm />` on the page. Submitting `email=maya@northwind`, `seats=4`, `newsletter=on` with the other fields empty makes the action return:

```json
{
  "status": "error",
  "initialValue": { "email": "maya@northwind", "newsletter": "on", "seats": "4" },
  "error": { "name": ["Name is required"], "email": ["Enter a valid email address"],
             "message": ["Message is required"] },
  "fields": ["name", "email", "seats", "newsletter", "message"]
}
```

The page re-renders with the values kept and the errors wired to the inputs, whether or not JavaScript ran:

```html
<input required id="inquiry-email" form="inquiry" aria-invalid="true"
  aria-describedby="inquiry-email-error" type="email" name="email" value="maya@northwind"/>
<div id="inquiry-email-error">Enter a valid email address</div>
```

A valid submission reaches `saveInquiry` as `{ name: 'Maya Okafor', email: 'maya@northwind.io', seats: 4, newsletter: true, message: '…' }` — already typed and coerced.

### Example 2: Invoice form with rows the user can add and remove

**User request:** "Build an invoice form with Conform where I can add and remove line items."

```tsx
'use client';
import { getFormProps, getInputProps, useForm } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod/v4';
import { z } from 'zod';

const invoiceSchema = z.object({
  customer: z.string().min(1),
  lines: z
    .array(z.object({ description: z.string().min(1), amount: z.number().positive() }))
    .min(1, 'Add at least one line'),
});

export function InvoiceForm() {
  const [form, fields] = useForm({
    defaultValue: { customer: 'Halvorsen Marine AS', lines: [{ description: '', amount: '' }] },
    shouldValidate: 'onBlur',
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: invoiceSchema });
    },
  });
  const lines = fields.lines.getFieldList();

  return (
    <form {...getFormProps(form)} method="post">
      <input {...getInputProps(fields.customer, { type: 'text' })} />
      {lines.map((line, index) => {
        const { description, amount } = line.getFieldset();
        return (
          <fieldset key={line.key}>
            <input {...getInputProps(description, { type: 'text' })} />
            <input {...getInputProps(amount, { type: 'number' })} step="0.01" />
            <div>{amount.errors}</div>
            <button {...form.remove.getButtonProps({ name: fields.lines.name, index })}>Remove</button>
          </fieldset>
        );
      })}
      <div>{fields.lines.errors}</div>
      <button {...form.insert.getButtonProps({ name: fields.lines.name })}>Add line</button>
      <button>Save invoice</button>
    </form>
  );
}
```

The browser posts `lines[0].description=Hull inspection`, `lines[0].amount=1450.00`, `lines[1].description=Antifouling paint`, `lines[1].amount=389.50`. On the server, `parseWithZod(formData, { schema: invoiceSchema }).value` is:

```json
{ "customer": "Halvorsen Marine AS",
  "lines": [{ "description": "Hull inspection", "amount": 1450 },
            { "description": "Antifouling paint", "amount": 389.5 }] }
```

An amount of `-5` in the second row comes back as `{ "lines[1].amount": ["Too small: expected number to be >0"] }`; removing every row gives `{ "lines": ["Add at least one line"] }`.

## Guidelines

- The server is the validator of record. Client validation in `onValidate` is a convenience; always parse the `FormData` again in the action.
- Conform renders `noValidate` on the form by default, even before hydration, so the browser's own `required`/`minLength` bubbles never show. Pass `defaultNoValidate: false` to `useForm` if you want native validation until JavaScript takes over. The constraint attributes still help screen readers either way.
- Constraints and Zod 4.6: with Conform 1.21.1, `getZodConstraint` and `getConstraints` return only `required` when Zod 4.6 or later is installed; `minLength`, `maxLength`, `min`, `max` and `pattern` are derived correctly up to Zod 4.5 (issue 1340 in the repository, open in September 2026). Validation is unaffected — only the HTML attributes are missing. Log the constraint object once after a Zod upgrade.
- Client validation is synchronous. For a check that needs the server (unique email), make the schema a function, add an issue with `conformZodMessage.VALIDATION_UNDEFINED` on the client so Conform falls back to the server, and call `parseWithZod(formData, { schema, async: true })` in the action.
- `form.reset` and `form.update` re-mount inputs through `key`. Inputs set up by hand need `key={fields.title.key}`; the `get*Props` helpers add it.
- Never echo secrets: `submission.reply({ hideFields: ['password'] })`, or `report(submission, { hideFields: [...] })` in the future API.
- Limit request size before parsing on the server (Next.js `serverActions.bodySizeLimit`, a multipart parser with file and part limits) — Conform parses whatever `FormData` it is given.
- The future API is experimental: its release notes list breaking changes in minor versions (1.20 renamed and reshaped options, 1.21 removed deprecated ones). Pin the exact version if you use it.
- When not to use Conform: a client-only form with no server round trip and heavy per-keystroke state (a spreadsheet-like editor) is simpler with a controlled-state library such as React Hook Form.

Files in this skill

  • SKILL.md3.1 KB
  • _scores.json1.7 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.