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:
- 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, andformError. - 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:
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:
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:
isPendinggetFieldError(field)getFieldErrorId(field)defaultValue/defaultCheckedaria-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
No comments yet.