Back to Home

typed-form-actions for Next.js forms with Zod

The typed-form-actions library simplifies creating typed forms in Next.js App Router. Automates FormData parsing, Zod validation and React state management without losing native HTML forms. Suitable for server-first approach with minimal client JS.

typed-form-actions: native typed Next.js forms
Advertisement 728x90

typed-form-actions: Type-Safe Form Actions for Native Forms in Next.js

Next.js developers often face repetitive code when handling forms: parsing FormData, building nested structures, validating with Zod, transforming errors for UI, and managing useActionState. The typed-form-actions library automates these tasks while preserving native HTML forms and a server-first approach.

The core idea is to unify FormData, Zod, and useActionState into a type-safe layer. Forms remain standard, yet gain automatic typing, error handling, and pending state—all without boilerplate.

The Pain Without Abstraction

In a typical Server Action flow with Zod, you’d see:

Google AdInline article slot
  • Extract FormData from the request.
  • Manually parse fields into an object.
  • Validate against a schema.
  • Convert Zod errors into Record<string, string[]>.
  • Return a standardized state with values, fieldErrors, and formError.
  • On the client: manually bind defaultValue, aria-invalid, and handle errors.

Example of typical code:

"use server";
import { z } from "zod";

const contactSchema = z.object({
  name: z.string().min(2, "Name is too short."),
  email: z.string().email("Enter a valid email."),
  message: z.string().min(10, "Message is too short."),
});

export async function sendMessage(prevState: any, formData: FormData) {
  const rawValues = {
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  };

  const parsed = contactSchema.safeParse(rawValues);
  if (!parsed.success) {
    return {
      status: "error",
      values: rawValues,
      data: null,
      formError: "Please correct the highlighted fields and try again.",
      fieldErrors: parsed.error.flatten().fieldErrors,
      submittedAt: Date.now(),
    };
  }
  // ...
}

This pattern repeats across every form. With arrays, checkboxes, or nested fields (like profile.name, links[0].href), the code grows exponentially.

Library API

The library is built on two key functions:

Google AdInline article slot

createFormAction

Accepts a Zod schema and handler, returns a ready-to-use Server Action:

import { createFormAction } from "typed-form-actions";
import { z } from "zod";

const newsletterSchema = z.object({
  email: z.string().email("Enter a valid email address."),
  topics: z.array(z.string()).min(1, "Pick at least one topic."),
  marketing: z
    .preprocess((value) => value === "on", z.boolean())
    .default(false),
});

export const subscribeAction = createFormAction({
  schema: newsletterSchema,
  async handler(values) {
    return {
      message: `Subscribed ${values.email}`,
      topicsCount: values.topics.length,
    };
  },
});

useActionForm

A client-side hook built on top of useActionState with a clean API:

"use client";

import { useActionForm } from "typed-form-actions/react";

import { subscribeAction } from "./actions";

export function NewsletterForm() {
  const form = useActionForm(subscribeAction);

  return (
    <form action={form.action}>
      <label>
        Email
        <input {...form.getInputProps("email", { id: "email", type: "email" })} />
        {form.getFieldError("email") ? (
          <p id={form.getFieldErrorId("email")}>
            {form.getFieldError("email")}
          </p>
        ) : null}
      </label>

      <fieldset>
        <legend>Topics</legend>
        <label>
          <input
            {...form.getInputProps("topics", {
              type: "checkbox",
              value: "react",
            })}
          />
          React
        </label>
        {/* Similar for other checkboxes */}
      </fieldset>

      {form.formError ? <p>{form.formError}</p> : null}

      <button disabled={form.isPending} type="submit">
        {form.isPending ? "Saving..." : "Subscribe"}
      </button>
    </form>
  );
}

Under the Hood

Parsing FormData

The library transforms flat FormData into nested objects:

Google AdInline article slot

Input:

{
  "profile.name": "Ada",
  "tags[]": ["react", "next"],
  "links[0].href": "/docs"
}

Output:

{
  profile: { name: "Ada" },
  tags: ["react", "next"],
  links: [{ href: "/docs" }]
}

Supports paths like profile.name, links[0].href, and repeated fields.

Unified FormActionState

type FormActionState<TValues, TResult> = {
  status: "idle" | "success" | "error";
  values: Partial<TValues>;
  data: TResult | null;
  fieldErrors: Record<string, string[]>;
  formError: string | null;
  submittedAt: number | null;
};

useActionForm automatically generates:

  • isPending
  • getFieldError(field)
  • getFieldErrorId(field)
  • defaultValue / defaultChecked
  • aria-invalid / aria-describedby

Comparison with react-hook-form

| Aspect | typed-form-actions | react-hook-form |

|--------|-------------------|-----------------|

| Native Forms | ✅ | ❌ (client-side) |

| Server Actions | ✅ | Through adapters |

| Client JS | Minimal | Significant |

| Zod Integration | Native | Via resolver |

| Complex Forms | Basic support | Full support |

This library doesn’t compete with RHF—it solves the server-first form problem in Next.js App Router.

Current Features

  • Parses FormData, URLSearchParams, plain objects
  • Nested structures and arrays
  • Zod validation
  • Predictable FormActionState
  • React API for errors and pending states
  • End-to-end typing

Limitations

  • No adapter for react-hook-form
  • Complex object arrays need refinement
  • Files and exotic inputs not covered
  • API may change

What Matters Most

  • Boilerplate automation: parsing, validation, errors—no manual code
  • Native forms: standard <form> elements, no uncontrolled components
  • Type safety: end-to-end from schema to UI
  • Server-first: minimal client JS, maximum platform benefits
  • Predictability: consistent FormActionState across all forms

— Editorial Team

Advertisement 728x90

Read Next