A server-first alternative to React Hook Form: Building better forms with Conform

A server-first alternative to React Hook Form: Building better forms with Conform

React Hook Form solved a real problem: too many re-renders, too much boilerplate. It did it well, and that's why it has 44.7k stars.

But it was built for a client-first world. Your validation lives on the client, your state lives on the client, and the server is just an endpoint you POST to when the user hits submit.

That model breaks down when you're working in Remix or Next.js server actions, where the server is a first-class citizen, not an afterthought. You end up manually wiring handleSubmit to server actions, catching server errors, and calling setError to push them back into form state. It works, but it's friction you're adding yourself.

Conform is built for that server-first model from the ground up. Same Zod schema, same validation, but it runs on both sides without the glue code.

This article walks through what Conform actually looks like in practice, where it beats RHF, and where it doesn't.

The React Hook Form approach

RHF's core insight was simple: stop storing form values in React state. Instead, read them directly from the DOM on submit via refs. Fewer state updates mean fewer re-renders, and for client-heavy forms, that matters.

Validation and type safety

Zod integration works through zodResolver you define a schema, pass it to useForm, and RHF handles client-side validation automatically. Clean, minimal setup.

Managing complex forms

useFieldArray handles dynamic lists. Dot notation handles nested objects register address.street and RHF maps it into a nested object on submit. For third-party UI components like Material UI or Shadcn, the Controller wrapper bridges the gap between those components and RHF's uncontrolled model.

The limitation shows up at the server boundary. Client validation works great, but server errors are your problem. You submit, await the response, then manually call setError to push server-side failures back into form state. It works you're just writing the plumbing yourself every time.

That's the gap Conform is designed to close.

What is Conform?

When you first use Conform, you’ll notice something important: it doesn't have certain features that other libraries do. There’s no register function for your inputs, no Controller component for connecting your UI library to the form state, and no need to manually call setError after your server responds.

Instead, the form simply functions like a standard HTML form. Conform builds on that foundation.

Conform is a headless form library designed around the FormData API and progressive enhancement. It doesn't take control of your form's state like React Hook Form (RHF) does. Instead, it improves what the browser already handles. Your Zod schema becomes the only guide for both client-side validation and server-side parsing. This means you only define the schema once, with no need for duplication or confusion between what the client checks and what the server expects.

In practice, you create a schema, use it with parseWithZod on the server and onValidate on the client, and Conform takes care of the rest, error handling, field information, ARIA attributes, and more. Your responsibilities become much smaller.

Conform has a simpler API than RHF. This isn't a flaw, it's intentional. Conform does less on the client because it relies on the server to handle more.

It's also built on progressive enhancement by default. Every form starts as a standard HTML form that works without JavaScript. Once JS loads, Conform layers on instant validation, complex state management, and efficient updates. If your framework is server-first, Conform slots in cleanly. If it isn't, you'll be fighting it.

Core features and concepts

Type-safe from end to end

One of Conform's standout features is its first-class support for schema validation libraries like Zod, Yup, and Valibot. By defining a single schema for your form data, Conform automatically infers all the necessary types. This saves you from having to keep different types for your form fields, server actions, and API endpoints. It also automatically converts form data from strings into numbers, dates, or booleans based on your schema. This feature helps avoid common errors and keeps your data accurate from the client to the database.

Fine-grained subscription

Modern frontend development focuses on performance, and this is important. Unnecessary re-renders can make the user interface slow. Conform is designed to be very efficient by allowing precise state subscription. Instead of re-rendering a component every time any part of the form state changes, Conform enables components to subscribe only to the specific fields or states they need. For example, if a user types in an email input, only the components that watch that email field will update, leaving the rest of the user interface unchanged.

Built-in accessibility helpers

Creating accessible forms can be tricky. It requires managing ARIA attributes like aria-invalid and aria-describedby correctly. Conform makes this easier by offering built-in tools for accessibility. Its API helps you connect error messages to the right input fields and automatically updates ARIA attributes based on whether the input is valid. This way, you can follow accessibility best practices without needing to become an expert in ARIA.

Headless by design

Conform is a headless library that helps you manage logic, state, and validation for your forms, without dictating how your user interface (UI) should look. It doesn't come with pre-styled components, giving you full control over the design of your forms. You can use standard HTML elements, a custom component library, or a UI framework like Tailwind UI or Shadcn/UI without having to adjust to the library's preferences. This flexibility allows Conform to fit smoothly into any design system.

Getting started with Conform

This section demonstrates the process of building a complete, functional contact form using Conform and Zod, highlighting the reduction in boilerplate compared to a manual approach. This example uses the Remix framework, but the principles apply to any server-side React framework.  

Project scaffolding and dependencies

Begin by setting up a new Remix project. Once the project is initialized, install the necessary Conform and Zod packages from the command line:

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

Defining the Zod schema

The Zod schema serves as the single source of truth for both client-side and server-side validation. It defines the shape of the form data and the validation rules for each field. The schema below defines rules for an email and a message field. The z.preprocess function is a crucial step for handling HTML inputs, which often return an empty string ('') for empty fields rather than undefined or null. Zod's required check works on undefined, so preprocessing the empty string to undefined allows for proper validation.  

// app/schema.ts
import { z } from 'zod';


export const contactFormSchema = z.object({
  email: z.preprocess(
    (value) => (value === ''? undefined : value),
    z.string({ required_error: 'Email is required' }).email('Email is invalid'),
  ),
  message: z.preprocess(
    (value) => (value === ''? undefined : value),
    z.string({ required_error: 'Message is required' }).min(10, 'Message is too short').max(100, 'Message is too long'),
  ),
});

Implementing the server-side action handler

The action function in a Remix route is where the server-side logic resides. Here, parseWithZod is used to efficiently process and validate the form data. If the submission is successful, the data is handled (e.g., sending a message). If validation fails, submission.reply() returns a structured object containing all form and field errors, which is then sent back to the client.  

// app/routes/contact.tsx
import { type ActionFunctionArgs, json } from '@remix-run/node';
import { parseWithZod } from '@conform-to/zod';
import { contactFormSchema } from '~/schema';
import { sendMessage } from '~/message'; // Assumed service

export async function action({ request }: ActionFunctionArgs) {
  const submission = await parseWithZod(await request.formData(), {
    schema: contactFormSchema,
  });

  if (submission.status!== 'success') {
    return json(submission.reply(), { status: 400 });
  }

  // Handle successful submission
  await sendMessage(submission.value);

  return json(submission.reply({ formErrors: ['Message sent successfully!'] }));
}

Building the client-side component

The React component uses useForm to manage the client-side form state and synchronize it with the server's response. The useActionData hook provides the lastResult from the action handler, allowing Conform to display server-side errors and persist the submitted data. The useForm hook's returned form and fields objects contain all the necessary metadata to render the form with built-in validation and accessibility.  

// app/routes/contact.tsx
import { useForm } from '@conform-to/react';
import { parseWithZod, getZodConstraint } from '@conform-to/zod';
import { type ActionFunctionArgs, json } from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
import { contactFormSchema } from '~/schema';
import { sendMessage } from '~/message';

export async function action({ request }: ActionFunctionArgs) {
  const submission = await parseWithZod(await request.formData(), {
    schema: contactFormSchema,
  });

  if (submission.status!== 'success') {
    return json(submission.reply(), { status: 400 });
  }
  // Handle successful submission
  await sendMessage(submission.value);
  return json(submission.reply({ formErrors: ['Message sent successfully!'] }));
}

export default function ContactUs() {
  const lastResult = useActionData<typeof action>();
  const [form, fields] = useForm({
    id: 'contact-form',
    lastResult: lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: contactFormSchema });
    },
    // getZodConstraint provides HTML validation attributes based on the schema
    constraint: getZodConstraint(contactFormSchema),
  });

  return (
    <Form method="post" {...form.props}>
      <h1>Contact Us</h1>
      {form.errors && <div>{form.errors}</div>}
      <div>
        <label htmlFor={fields.email.id}>Email</label>
        <input {...fields.email.props} />
        {fields.email.errors && <div>{fields.email.errors}</div>}
      </div>
      <div>
        <label htmlFor={fields.message.id}>Message</label>
        <textarea {...fields.message.props} />
        {fields.message.errors && <div>{fields.message.errors}</div>}
      </div>
      <button type="submit">Send Message</button>
    </Form>
  );
}

The use of useForm significantly improves the development experience. The form.props object automatically applies attributes like id and noValidate to the <Form> element, while fields.email.props provides the nameidaria-invalid, and aria-describedby attributes to the input, eliminating the error-prone manual management of these properties.  

Advanced use cases and solutions

Dynamic lists

Handling an unknown number of repeating fields, such as a list of tags or skills, is a common requirement. Conform addresses this with a combination of client-side hooks and a simple naming convention. The useForm hook's returned fields object provides a getFieldList() method, which is specifically designed for iterating over dynamic arrays of inputs.  

To manage the list dynamically (adding, removing, or reordering items), Conform utilizes the intent dispatcher. This feature allows for programmatic actions that update the form state without a full page reload or a complete form submission.

import { useForm } from '@conform-to/react';
import { z } from 'zod';

const tagsSchema = z.object({
  tags: z.array(z.string()),
});

export default function TagsForm() {
  const [form, fields] = useForm({
    defaultValue: {
      tags: [],
    },
  });

  // getFieldList() provides an array of field metadata to render the list
  const tags = fields.tags.getFieldList();

  return (
    <form {...form.props} noValidate>
      <h2>Manage Tags</h2>
      {tags.map((tag, index) => (
        <div key={tag.key}>
          <label htmlFor={tag.id}>Tag {index + 1}</label>
          <input {...tag.props} />
          {tag.errors && <div>{tag.errors}</div>}
          {/* Button to remove a tag using the 'remove' intent */}
          <button type="button" onClick={() => form.remove({ name: tag.name })}>
            Remove
          </button>
        </div>
      ))}
      {/* Button to add a new tag using the 'insert' intent */}
      <button type="button" onClick={() => form.insert({ name: fields.tags.name })}>
        Add Tag
      </button>
      <button type="submit">Save</button>
    </form>
  );
}

Nested objects and arrays of objects

The FormData API, which powers standard HTML form submissions, has a flat structure where each input name corresponds to a single key. Representing complex, hierarchical data, such as a user with a nested address object or a list of user objects, is a significant challenge. Conform provides an elegant solution by formalizing a specific naming convention that bridges the gap between the flat  

FormData object and a nested Zod schema. This approach is not entirely new, with historical precedents in other languages like PHP. The innovation lies in making this a consistent, type-safe pattern across the entire application stack.  

  • For Nested Objects: Use dot notation (.) to access properties within an object. For a Zod schema with a nested address object, the inputs are named address.streetaddress.city, etc.
  • For Arrays of Objects: Use bracket and dot notation ([index].) to specify the index and property within an array. For an array of users, the inputs are named users.nameusers.age, and so on.  

The following table illustrates this crucial mapping: Zod schema vs. HTML naming convention

Zod Schema

HTML Input Name

z.object({ name: z.string() })

<input name="name" />

z.object({ address: z.object({ street: z.string() }) })

<input name="address.street" />

z.object({ tags: z.array(z.string()) })

<input name="tags" /> (multiple inputs)

z.object({ users: z.array(z.object({ name: z.string() })) })

<input name="users.name" />

The power of this convention is that it creates a seamless, end-to-end validation pipeline. The Zod schema serves as a blueprint for the expected data structure, and Conform guarantees that the parsed submission.data on the server will match that schema’s nested structure, eliminating the need for manual data restructuring and reducing a significant source of errors.  

import { useForm } from '@conform-to/react';
import { z } from 'zod';
import { Form, useActionData } from '@remix-run/react';

const usersSchema = z.object({
  users: z.array(z.object({
    name: z.string(),
    email: z.string().email(),
  })),
});

export default function UsersForm() {
  const lastResult = useActionData<typeof action>();
  const [form, fields] = useForm({
    lastResult,
    defaultValue: {
      users: [{ name: '', email: '' }],
    },
  });

  const userFields = fields.users.getFieldList();

  return (
    <Form {...form.props}>
      <h2>Add Users</h2>
      {userFields.map((user, index) => {
        const userFieldset = user.getFieldset();
        return (
          <fieldset key={user.key}>
            <legend>User {index + 1}</legend>
            <label htmlFor={userFieldset.name.id}>Name</label>
            <input {...userFieldset.name.props} />
            {userFieldset.name.errors && <div>{userFieldset.name.errors}</div>}
            <label htmlFor={userFieldset.email.id}>Email</label>
            <input {...userFieldset.email.props} />
            {userFieldset.email.errors && <div>{userFieldset.email.errors}</div>}
          </fieldset>
        );
      })}
      <button type="submit">Submit</button>
    </Form>
  );
}

Handling multiple submission intents

A common requirement is a single form that can perform different actions based on which button is clicked (e.g., Save draft vs. Publish). Conform elegantly solves this by using a simple yet effective "intent" pattern. This is achieved by giving each submit button the same name attribute but a different value. When the form is submitted, the FormData object will include a field that identifies the specific action, which can then be used to branch the server-side logic.

// Server-side action handler
import { type ActionFunctionArgs } from '@remix-run/node';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
import { savePost, publishPost } from '~/post-service'; // Assumed services

const postSchema = z.object({
  title: z.string().min(1),
  content: z.string().min(10),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const intent = formData.get('intent');
  const submission = parseWithZod(formData, { schema: postSchema });

  if (submission.status!== 'success') {
    return json(submission.reply(), { status: 400 });
  }

  // Use a switch statement to handle different intents
  switch (intent) {
    case 'save-draft':
      await savePost(submission.value);
      return json(submission.reply({ formErrors: ['Post saved as draft!'] }));
    case 'publish':
      await publishPost(submission.value);
      return json(submission.reply({ formErrors: ['Post published successfully!'] }));
    default:
      return json(submission.reply(), { status: 400 });
  }
}

On the client side, the <Form> simply includes two submit buttons with the name attribute set to intent and a descriptive value.

import { Form } from '@remix-run/react';

export default function PostForm() {
  return (
    <Form method="post">
      {/* Input fields for title and content */}
      <input type="text" name="title" />
      <textarea name="content" />
      {/* 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>
  );
}

Seamless UI component library integration

Integrating form libraries with UI component libraries like Shadcn UI, Material UI, or Chakra UI often leads to tight coupling and a loss of flexibility. Conform provides a flexible solution to this common problem through its useControl hook. This hook acts as a bridge, allowing a custom, non-native UI component to interact with a hidden, native form input that Conform can manage.  

The architectural pattern is elegant and non-intrusive. The core logic of the form remains tied to the underlying native input, which is handled by Conform. The custom UI component becomes a separate, purely presentational layer that simply syncs its visual state with this hidden input. This powerful decoupling means developers can leverage the full styling, accessibility, and features of their chosen design system without any compromise to the form's functionality or type safety.  

The implementation involves two key steps:

  1. Rendering a hidden native input: A native <input><select>, or <textarea> is rendered with the hidden attribute. Its ref prop is set to control.register to link it to the useControl hook. 
  2. Delegating state and events: The state from the useControl hook (control.checkedcontrol.filescontrol.options) is passed to the custom component's props. Conversely, events from the custom component (e.g., onChangeonBlur) are delegated back to the useControl corresponding helper functions (control.change()control.blur()). The control.change() function is particularly important as it updates the hidden input's value and programmatically dispatches native events that Conform's form state relies on.  
// Example of integrating a custom Checkbox component
import { useControl } from '@conform-to/react/future';
import { useForm } from '@conform-to/react';
import { Checkbox } from './custom-checkbox-component'; // Assumed component


function Example() {
  const [form, fields] = useForm({
    defaultValues: { newsletter: true },
  });
  const control = useControl({
    defaultChecked: fields.newsletter.defaultChecked,
  });


  return (
    <>
      {/* Hidden native checkbox, managed by Conform */}
      <input
        type="checkbox"
        name={fields.newsletter.name}
        ref={control.register}
        hidden
      />
      
      {/* Custom UI component, synced with the hidden input */}
      <Checkbox
        checked={control.checked}
        onChange={(checked) => control.change(checked)}
        onBlur={() => control.blur()}
      >
        Subscribe to newsletter
      </Checkbox>
    </>
  );
}

React Hook Form vs Conform

To clearly show the differences in design and use between these two libraries, it's important to compare them side by side:

Feature

React Hook Form

Conform

Primary philosophy

Minimize re-renders. Uncontrolled inputs, ref-based reads, Virtual DOM stays out of it as much as possible.

Progressive enhancement. The browser manages the form; Conform enhances it. Server is a first-class citizen, not a POST target.

Server integration

You wire it yourself. handleSubmit -> server action → setError for any server-side failures. Works, but you're writing the glue.

Built around the server response cycle. submission.reply() propagates errors back to the client without manual mapping.

Validation model

Client-side by default. Zod works via zodResolver, but it's a client-only adapter. Server validation is a separate concern you connect manually.

One Zod schema runs on both sides. parseWithZod on the server, onValidate on the client. No drift between what each side checks.

Nested and dynamic fields

useFieldArray for dynamic lists. Dot notation for nested objects. Mature, well-documented, lots of examples in the wild.

getFieldset() and getFieldList() built on a naming convention. Less community precedent, but the pattern is consistent and type-safe end-to-end.

Performance

Optimizes within React fewer Virtual DOM updates on keystroke. The benchmark story is well-documented.

Offloads state to the browser where possible. Fewer React renders because React owns less. Different optimization target, not a worse one.

Ecosystem

43.9k stars, years of production usage, large community, abundant third-party examples. When something breaks, someone has already hit it.

Smaller footprint, tighter API, less churn. The tradeoff: fewer answers on Stack Overflow, fewer ready-made examples to copy from.

Conclusion

Conform isn't a universal upgrade over React Hook Form. Here's the honest breakdown:

Use Conform if:

  • You're building in Remix or Next.js with server actions as the primary data flow
  • Server validation errors are a first-class concern, not an edge case you handle with setError
  • You want progressive enhancement without building it yourself
  • You're starting a new project and aren't already invested in the RHF ecosystem

Stick with React Hook Form if:

  • You're on an existing codebase with RHF already wired in; the migration cost isn't worth it
  • Your app is heavily client-side with minimal server interaction
  • You need the broader ecosystem: more community examples, more third-party integrations, more Stack Overflow answers when things break

The deeper point is this: Conform and React Hook Form aren't solving the same problem anymore. RHF is optimizing the client. Conform is optimizing the client-server boundary. If your framework treats the server as a first-class citizen, your form library probably should too.

That's the case for Conform. Not that it's newer or more principled, but that it fits the architecture you're already building in.

Customize your view

Manage your font size, color, and background

Font size

Aa

Aa

Color

Background

Light
Dim
Dark