npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-router-zod-forms

v1.2.1

Published

Forms management for React Router 7 applications using Zod 4 for validation and schemas

Readme


Documentation

React Router Zod Forms aims to simplify the process of handling form submission and validation with React Router and Zod 4.

[!NOTE] Requires React 19, React Router 7 and zod 4

Installation

npm install react-router-zod-forms

Schema Definition

Form schemas are zod objects where the top-level key is the "intent" of the form, and the value is the actual form schema. This simplifies the process of creating multiple forms on the same page.

In the example below, the general intent indicates a schema with keys title, content and settings.

import z from "zod";

const schema = z.object({
  /**
   * General settings
   */
  general: z.object({
    title: z
      .string()
      .meta({ description: "Page Title" }),
    
    content: z
      .string()
      .meta({ description: "Page Description" }),
    
    settings: z
      .array(
        z.coerce.string()
      )
      .meta({ description: "Page Settings" }),
  }),
});

[!IMPORTANT] The root-level schema must consist of object types only. Polluting the schema with non-object types will cause a type error in handleZodForm.

Server Handler

To handle form submission in a route action, use the handleZodForm method.

import { handleZodForm } from "react-router-zod-forms/server";

export const action = async ({ context, params, request }: Route.ActionArgs) => {
  return await handleZodForm(
    {
      request,
      schema,
    },
    {
      async general ({
        data: {
          title,
          content,
          settings,
        },
        response,
        validation,
      }) {
        // `validation` contains zod's `safeParse` result
        if (validation.success) {
          await Page.updateOne(
            {
              slug: params.slug,
            },
            {
              $set: {
                title,
                content,
                settings,
              },
            }
          );

          // The `response` object can be mutated directly, you
          // don't have to return it but you can if you want
          response.message = `Changes to page '${title}' saved successfully`;

          // If you need to return a Response, ie. a redirect or
          // cookie header, simply throw it instead
          throw redirect(...);
          throw Response(...);

          // Throwing an Error will populate the `response` object
          // with the error message and return it with status 500
          throw new Error("Something went wrong!");
        }
      },
    }
  );
};

The handleZodForm method parses the current request for FormData, performs any required file uploads, and processes forms based on a given intent.

Arguments

1. options (required) – Form handler configuration object

| Property | Type | Effect | | - | - | - | | request (required) | Request | The current request | | schema (required) | ZodObject | Your zod schema object | | maxFileSize | number | Set the maximum file size for file uploads (see @mjackson/multipart-parser) | | maxHeaderSize | number | Set the maximum header size for multipart payloads (see @mjackson/multipart-parser) | | messages | object | Supply your own default message text for error, success and notImplemented responses | | transform | function | Transforms the value of each formData field before it is parsed by Zod (arguments are key: string, value: any and path: (number \| string)[]) |

2. forms (required) – Form handlers corresponding to schema entries

For every ZodObject within your schema, you can define a namesake form handler function inside the forms object. For instance;

const schema = z.object({
  primary: z.object({ primary_title: z.string() }),
  secondary: z.object({ secondary_title: z.string() }),
});

return await handleZodForm({ request, schema }, {
  async primary ({ data: { primary_title } }) { ... },
  async secondary ({ data: { secondary_title } }) { ... },
});

If you have some custom action functionality that happens outside of React Router Zod Forms, you should put it inside the default handler, like so;

return await handleZodForm({ request, schema }, {
  async default ({ formData, intent }) {
    console.log(
      `Unhandled intent '${ intent }' with data:`, formData.entries()
    );
  },
  ...
});

[!NOTE] handleZodForm will always return a ZodForms.Response object, unless a Response is thrown specifically.

3. hooks – Event callbacks that help you modify the form data before and after it is parsed and validated

| Hook | Properties | Returns | Effect | | - | - | - | - | | before | data: FormData | | Called before form data is cast to a POJO and before validation occurs | | after | data: FormData | | Called after all relevant handlers have executed | | beforeValidate | data?: z.output<typeof schema> | z.output<typeof schema> | Called before zod validation. May be used to mutate the form data before validation | | afterValidate | result?: ZodSafeParseResult<z.output<typeof schema>> | ZodSafeParseResult<z.output<typeof schema>> | Called after zod validation. May be used to mutate the validation response before action handling |

[!NOTE] Hooks are not required to return a value

File Uploads

Uploaded files are parsed by multipart-parser under-the-hood and converted to FileUpload instances before getting delivered to your handler data.

File fields should implement z.instanceof(File) in the schema for correct validation.

You can manipulate files directly from your handlers alongside all other form data. For instance;

const schema = z.object({
  decode: z.object({
    file: z.instanceof(File),
  }),
  upload: z.object({
    file: z.instanceof(File),
  }),
});

return await handleZodForm({ request, schema }, {
  async upload ({ data: { file } }) {
    const client = new S3Client(s3config);

    const upload = new Upload({
      client,
      params: {
        Body: await file.arrayBuffer(),
        Bucket: "storage",
        Key: file.name,
      },
    });

    await upload.done();
  },
    
  async decode ({ data: { file } }) {
    const contents = someDecoderLibrary.decode(file.stream());
  
    ...

Type Generics

The handleZodForm method accepts the following type parameters;

| Generic | Type | Effect | | - | - | - | | SchemaType | z.ZodObject<Record<string, z.ZodObject<any>>> | Input schema type. Should be typeof schema in almost every case | | PayloadTypes | Record<"default" \| keyof SchemaType[ "_zod" ][ "def" ][ "shape" ], any> | Fetcher data payload type map |

PayloadTypes is particularly useful when you need type safety for your form action's data payload (see Payload Type Safety).

Client Hook

Use the useZodForm hook to initialize a form for use within your page or custom components. It accepts a single argument – options – which requires intent (string) and schema (ZodObject).

If you want to use a fetcher to submit your form, set options.useFetcher to true.

import { useZodForm } from "react-router-zod-forms";

export default function Component () {
  const {
    // Populated with action data
    data,

    // The form's unique identifier
    id,

    // The current form intent
    intent,

    // Passed through from the fetcher if `useFetcher` is `true`
    load,
    submit,

    // Fetcher or form navigation state
    state,

    // Method to manually call client-side field validation
    validate,

    // Zod validation result
    validation,

    // Form components – more on these below
    Field,
    Form,
    Message,
  } = useZodForm(
    {
      intent: "general",
      schema,

      // Fetcher is disabled by default
      useFetcher: true,
    }
  );

  return (
    <>
      <Form action="?index" className="form">
        <Message />
        <fieldset>
          <legend>
            { schema.def.shape.general.def.shape.title.meta()?.description }
          </legend>
          <Field name="title" />
          <Message name="title" />
        </fieldset>
        <fieldset>
          <legend>
            { schema.def.shape.general.def.shape.content.meta()?.description }
          </legend>
          <Field name="content" type="textarea" />
          <Message name="content" />
        </fieldset>
        <fieldset>
          <legend>
            { schema.def.shape.general.def.shape.settings.meta()?.description }
          </legend>
          <Field name="settings[0]" />
          <Field name="settings[1]" />
          <Field name="settings[2]" />
          <Message name="settings.*" />
        </fieldset>
        <button disabled={ state !== "idle" }>
          submit
        </button>
      </Form>
    </>
  );
}

Arguments

1. options (required) – Form context configuration object

| Property | Type | Effect | | - | - | - | | intent (required) | string | The current form intent | | schema (required) | ZodObject | Your zod schema object | | events | string[] | Names of event handlers that will trigger form validation | | useFetcher | boolean | Whether to use a fetcher to submit the form. Defaults to false |

<Form /> component

The Form component is an extension of React Router's Form component.

It handles validation with zod and automatically inserts a hidden intent field so that handleZodForm knows which schema to use.

Form components set method to post by default but you can override this by adding your own method attribute;

<Form method="get">

The Form component accepts a custom property called intent that allows you to enable or disable the automatic intent field. This is useful if you want to utilize multiple handlers from the same form by specifying your own _intent field.

In the below example, clicking the Save button will submit the form to the api handler, whereas clicking the Clear or Test buttons will submit the same information to the clear or test handlers, respectively. If a handler does not exist for clear or test (i.e. the schema does not contain a key for them), then the default handler will be used instead.

const { intent, Field, Form } = useZodForm({
  intent: "api",
  schema,
});

return (
  <Form intent={ false }>
    <Field name="endpoint" />
    <button name="_intent" value={ intent }>
      Save
    </button>
    <button name="_intent" value="clear">
      Clear
    </button>
    <button name="_intent" value="test">
      Test
    </button>
  </Form>
);

[!NOTE] Be aware that submitting a form to a different intent may cause validation errors if the schema for the target intent does not contain fields for the submitted form, or if the schema field types do not match.

<Field /> component

The Field component is a wrapper for elements like input, select and textarea and should be used in place of said elements.

The name attribute is strongly typed to only accept valid keys from your schema, and the value and type attributes are typed accordingly.

To render a select field, you can set the Field's type to select and add option elements as children directly to the Field;

<Field name="select_field" type="select">
  <option value="option_1">Option 1</option>
  ...
</Field>

Objects

Schemas can be nested as deeply as you need. Field keys can reflect nested types using dot notation;

<Field name="deeply.nested.field.name" />

Arrays

Handling array schemas is as simple as using square bracket notation for array indices;

itemsState.map(
  (item, key) => (
    <Field key={ key } name={ `items[${ key }]` } defaultValue={ item.value } />
  )
)

And, of course, you can mix dot and bracket notations as required;

itemsState.map(
  (item, key) => (
    <Field key={ key } name={ `deeply.nested.items[${ key }].name` } defaultValue={ item.name } />
  )
)

Custom Components

As custom components are commonplace in forms, Field components also accept a function in place of its children property. To render a custom Select component, for instance;

<Field name="select_field">
  { (props: SelectProps) => (
    <Select { ...props }>
      <SelectItem>Option 1</SelectItem>
      ...
    </Select>
  ) }
</Field>

[!WARNING] By default, props is typed as AllHTMLAttributes<HTMLElement>, but this may cause problems with your custom component's property types. If that's the case, utilize destructuring to deliver the properties you need;

  { ({ name, required }) => (
    <Select name={ name } required={ required }>
      ...

The children functional property receives two arguments; the first is input props, and the second is the zod schema (or "shape") for that specific field. You can use this second argument to retrieve a field's metadata from your schema. For example;

const schema = z.object({
  form: z.object({
    field: z.string().meta({ description: "Field Name" })
  }),
});

<Field name="field">
  { (props: SelectProps, { meta }) => (
    <input
      { ...props }
      placeholder={
        meta?.()?.description // "Field Name" | undefined
      } />
  ) }
</Field>

<Message /> component

The Message component displays response or validation data depending on its name attribute.

<Message name="title" />

If name matches a valid field in your schema, the component will display any validation error messages relating to that field. You can end the name attribute with a wildcard (.*) to catch all errors nested within a given field;

<Message name="settings.*" />

You can display all validation errors at once by passing in the catchall * wildcard;

<Message name="*" />

Omitting name will cause the component to display the message sent back from handleZorm's response payload.

Custom Components

The Message component can also take advantage of custom components via a functional children prop. It receives additional properties that you should destructure accordingly;

For field messages, use the issues prop to access zod's validation issues list.

<Message name="select_field">
  { ({ issues }) => (
    <div>
      <h4>Field contains <strong>{ issues.length }</strong> errors:</h4>
      <ul>
        { issues.map(issue => (
          <li>{ issue.message }</li>
        )) }
      </ul>
    </div>
  ) }
</Message>

For form messages, use the message prop to access the response payload from the server.

<Message>
  { ({ message }) => (
    <p>
      <strong>
        { message.status === 200
          ? "Success"
          : "Error" }
      </strong>: { message.message }
    </p>
  ) }
</Message>

Payload Type Safety

Sometimes you need to deliver some extra data from your actions. The response.payload property can be used to deliver data to your forms like so;

type SchemaPayloads = {
  primary: {
    enabled: boolean;
  };
};

export const action = async ({ context, params, request }: Route.ActionArgs) => {
                // Note the second type parameter ↴
  return await handleZodForm<typeof schema, SchemaPayloads>({ request, schema }, {
    async primary ({ response }) {
      response.payload.enabled = true;
    },
  });
}

export default function Component () {
      // Note the second and third type parameters ↴ ––––––––––––↴
  const { data } = useZodForm<typeof schema, SchemaPayloads, "general">({ intent: "general", schema });

  console.log(data.payload.enabled); // true
}

Multiple Forms

You can create multiple forms on the same page by initializing each form as a variable rather than destructuring;

const schema = z.object({
  primary: z.object({ primary_title: z.string() }),
  secondary: z.object({ secondary_title: z.string() }),
});

export default function Component () {
  const primary = useZodForm({ intent: "primary", schema });
  const secondary = useZodForm({ intent: "secondary", schema });

  return (
    <>
      <primary.Form>
        <primary.Message />
        <fieldset>
          <legend>
            Primary Title
          </legend>
          <primary.Field name="primary_title" />
        </fieldset>
        <button disabled={
          primary.state !== "idle"
        }>
          submit
        </button>
      </primary.Form>

      <secondary.Form>
        <secondary.Message />
        <fieldset>
          <legend>
            Secondary Title
          </legend>
          <secondary.Field name="secondary_title" />
        </fieldset>
        <button disabled={
          secondary.state !== "idle"
        }>
          submit
        </button>
      </secondary.Form>
    </>
  );
}

Contributing

If you're interested in contributing to React Router Zod Forms, please feel free to make a pull request!