Building a Type-Safe API Layer with Server Actions and Zod
Server Actions solved a real problem. No more writing API routes, no more fetch boilerplate, no more manually keeping client and server types in sync. You write a function, mark it "use server", and call it from your component. It's elegant.
Until you have forty of them, and every single one has its own ad-hoc validation, its own error shape, and its own way of handling failures. I've seen codebases where half the server actions return { success: boolean }, a third return { error: string }, and the rest just throw. The type safety that TypeScript gives you at compile time vanishes the moment real user input hits the server. Here's how I fixed that with Zod and a thin wrapper that took about 30 lines to build.
The Problem With Raw Server Actions
Here's what a typical server action looks like in the wild:
"use server";
import { db } from "@/lib/db";
export async function createProject(formData: FormData) {
const name = formData.get("name") as string;
const budget = Number(formData.get("budget"));
const clientEmail = formData.get("clientEmail") as string;
// No validation — budget could be NaN, name could be empty,
// clientEmail could be "definitely not an email"
const project = await db.project.create({
data: { name, budget, clientEmail },
});
return project;
}This has three problems that will bite you in production:
-
No input validation.
formData.get()returnsFormDataEntryValue | null. Casting tostringdoesn't validate anything — it just lies to TypeScript. Ifbudgetis"abc", you're insertingNaNinto your database. -
No consistent error handling. If
db.project.createthrows, what does the client get? An unhandled rejection. No structured error. No way to display a meaningful message. -
No type inference. The caller has no idea what this function actually expects. The types are
FormDatain,unknownout. You lose the entire benefit of TypeScript.
This compounds fast. You end up writing the same try/catch, the same if (!name) return { error: "Name is required" } checks, the same parsing logic in every single action.
Enter Zod
If you haven't used Zod, it's a TypeScript-first schema validation library. You define a schema, and Zod gives you two things: runtime validation and a TypeScript type inferred from that schema. One source of truth for both.
import { z } from "zod";
const CreateProjectSchema = z.object({
name: z.string().min(1, "Project name is required").max(100),
budget: z.number().positive("Budget must be positive"),
clientEmail: z.string().email("Invalid email address"),
});
// TypeScript type, inferred automatically
type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
// { name: string; budget: number; clientEmail: string }The schema is both your validation logic and your type definition. When you change the schema, the types update automatically. No drift.
The Action Wrapper
Here's where it comes together. We build a createAction function that takes a Zod schema and a handler, and returns a server action with validated input, typed output, and consistent error handling. If you've read the design patterns series, you'll recognize this as essentially a Template Method — the wrapper defines the skeleton (validate, execute, catch), and each action fills in the specific behavior.
// lib/action.ts
"use server";
import { z } from "zod";
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string; fieldErrors?: Record<string, string[]> };
export function createAction<TInput, TOutput>(
schema: z.ZodSchema<TInput>,
handler: (validatedInput: TInput) => Promise<TOutput>
) {
return async (input: TInput): Promise<ActionResult<TOutput>> => {
const parsed = schema.safeParse(input);
if (!parsed.success) {
const fieldErrors: Record<string, string[]> = {};
for (const issue of parsed.error.issues) {
const key = issue.path.join(".");
if (!fieldErrors[key]) fieldErrors[key] = [];
fieldErrors[key].push(issue.message);
}
return {
success: false,
error: "Validation failed",
fieldErrors,
};
}
try {
const data = await handler(parsed.data);
return { success: true, data };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "An unexpected error occurred",
};
}
};
}That's it. About 35 lines. Every action now gets:
- Runtime validation before the handler runs
- Typed input inferred from the schema (no
as stringcasts) - Structured field errors mapped to form fields
- Consistent return type — always
ActionResult<T> - Caught exceptions — no unhandled rejections leaking to the client
Using the Wrapper
Let's rewrite that project creation action and add a few more real-world examples.
Create a Project
// actions/projects.ts
"use server";
import { z } from "zod";
import { createAction } from "@/lib/action";
import { db } from "@/lib/db";
const CreateProjectSchema = z.object({
name: z.string().min(1, "Project name is required").max(100),
budget: z.number().positive("Budget must be positive"),
clientEmail: z.string().email("Invalid email address"),
startDate: z.coerce.date().min(new Date(), "Start date must be in the future"),
});
export const createProject = createAction(CreateProjectSchema, async (input) => {
const project = await db.project.create({
data: {
...input,
status: "DRAFT",
},
});
return { id: project.id, name: project.name };
});Notice how input is fully typed — your editor autocompletes input.name, input.budget, input.clientEmail, and input.startDate with the correct types. No casting. No guessing.
Update User Settings
// actions/settings.ts
"use server";
import { z } from "zod";
import { createAction } from "@/lib/action";
import { db } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
const UpdateSettingsSchema = z.object({
displayName: z.string().min(2).max(50).optional(),
timezone: z.string().refine(
(tz) => Intl.supportedValuesOf("timeZone").includes(tz),
"Invalid timezone"
),
emailNotifications: z.boolean(),
weeklyDigest: z.boolean(),
});
export const updateSettings = createAction(UpdateSettingsSchema, async (input) => {
const user = await getCurrentUser();
if (!user) throw new Error("Not authenticated");
return db.userSettings.update({
where: { userId: user.id },
data: input,
});
});Delete an Item (With Ownership Check)
// actions/invoices.ts
"use server";
import { z } from "zod";
import { createAction } from "@/lib/action";
import { db } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
const DeleteInvoiceSchema = z.object({
invoiceId: z.string().uuid("Invalid invoice ID"),
});
export const deleteInvoice = createAction(DeleteInvoiceSchema, async (input) => {
const user = await getCurrentUser();
if (!user) throw new Error("Not authenticated");
const invoice = await db.invoice.findUnique({
where: { id: input.invoiceId },
});
if (!invoice) throw new Error("Invoice not found");
if (invoice.userId !== user.id) throw new Error("Not authorized");
if (invoice.status === "PAID") throw new Error("Cannot delete a paid invoice");
await db.invoice.delete({ where: { id: input.invoiceId } });
return { deleted: true };
});Client-Side Integration
The real payoff is how cleanly these typed actions integrate with React. Here's a project creation form using useActionState:
"use client";
import { useActionState } from "react";
import { createProject } from "@/actions/projects";
export function CreateProjectForm() {
const [state, action, isPending] = useActionState(
async (_prev: Awaited<ReturnType<typeof createProject>> | null, formData: FormData) => {
return createProject({
name: formData.get("name") as string,
budget: Number(formData.get("budget")),
clientEmail: formData.get("clientEmail") as string,
startDate: new Date(formData.get("startDate") as string),
});
},
null
);
return (
<form action={action}>
<div>
<label htmlFor="name">Project Name</label>
<input id="name" name="name" required />
{state?.success === false && state.fieldErrors?.name && (
<p className="text-sm text-red-500">{state.fieldErrors.name[0]}</p>
)}
</div>
<div>
<label htmlFor="clientEmail">Client Email</label>
<input id="clientEmail" name="clientEmail" type="email" required />
{state?.success === false && state.fieldErrors?.["clientEmail"] && (
<p className="text-sm text-red-500">{state.fieldErrors["clientEmail"][0]}</p>
)}
</div>
{/* ...additional fields follow the same pattern */}
{state?.success === false && !state.fieldErrors && (
<p className="text-sm text-red-500">{state.error}</p>
)}
{state?.success === true && (
<p className="text-sm text-green-600">Project "{state.data.name}" created.</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Project"}
</button>
</form>
);
}Types flow end to end. The state variable is ActionResult<{ id: string; name: string }> | null. Your editor knows that state.data.name exists when state.success is true, and that state.fieldErrors exists when it's false. No manual type annotations on the client.
Error Handling Patterns
There are two kinds of errors your actions will produce, and conflating them is a common mistake.
Field-level errors come from Zod validation. They map directly to form inputs — "Email is invalid", "Name is required". The fieldErrors record in ActionResult handles these. Render them inline next to the relevant input.
Action-level errors come from your handler logic — "Not authenticated", "Invoice not found", "Cannot delete a paid invoice". These are the error string without fieldErrors. Render them as a banner or toast above the form.
Here's a hook that makes this distinction clean:
// hooks/use-form-errors.ts
import { useMemo } from "react";
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string; fieldErrors?: Record<string, string[]> }
| null;
export function useFormErrors<T>(state: ActionResult<T>) {
return useMemo(() => {
if (!state || state.success) {
return { fieldError: () => undefined, actionError: undefined };
}
return {
fieldError: (field: string) => state.fieldErrors?.[field]?.[0],
actionError: state.fieldErrors ? undefined : state.error,
};
}, [state]);
}Now the form component stays clean:
const { fieldError, actionError } = useFormErrors(state);
// Inline next to input
{fieldError("clientEmail") && (
<p className="text-sm text-red-500">{fieldError("clientEmail")}</p>
)}
// Banner at the top
{actionError && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700">{actionError}</div>
)}Middleware Pattern
Once you have the wrapper, you'll want to compose cross-cutting concerns — auth, rate limiting, logging — without cluttering every handler. Here's an extended version that supports middleware:
Extend createAction to accept an optional middlewares array. Each middleware gets a shared ctx object and a next function. The wrapper runs them in order before the handler, and the handler receives ctx alongside the validated input.
Define reusable middleware functions:
// lib/middlewares.ts
import { getCurrentUser } from "@/lib/auth";
import { ratelimit } from "@/lib/ratelimit";
export const requireAuth: Middleware = async (ctx, next) => {
const user = await getCurrentUser();
if (!user) throw new Error("Not authenticated");
ctx.userId = user.id;
await next();
};
export const withRateLimit = (limit: number, window: string): Middleware => {
return async (ctx, next) => {
if (!ctx.userId) throw new Error("Rate limiting requires auth");
const { success } = await ratelimit(ctx.userId, limit, window);
if (!success) throw new Error("Too many requests. Try again later.");
await next();
};
};
export const withLogging = (actionName: string): Middleware => {
return async (ctx, next) => {
const start = Date.now();
await next();
console.log(`[${actionName}] userId=${ctx.userId} duration=${Date.now() - start}ms`);
};
};And compose them per action:
export const createProject = createAction(
CreateProjectSchema,
async (input, ctx) => {
return db.project.create({
data: { ...input, userId: ctx.userId!, status: "DRAFT" },
});
},
{
middlewares: [
requireAuth,
withRateLimit(10, "1m"),
withLogging("createProject"),
],
}
);Each action declares exactly what it needs. Auth-only actions get requireAuth. Public actions skip it. Rate-limited mutations get withRateLimit. The handler stays focused on business logic.
When to Use What
Server Actions with Zod aren't always the right call. Here's how they compare to the alternatives:
| Server Actions + Zod | tRPC | API Routes | |
|---|---|---|---|
| Type safety | Schema-inferred, end-to-end | Full end-to-end via router | Manual, requires shared types |
| Setup cost | ~35 lines for the wrapper | Router setup, client provider | Per-route boilerplate |
| Best for | Next.js apps with forms and mutations | Full-stack apps needing typed queries + mutations | Public APIs, webhooks, third-party integrations |
| Progressive enhancement | Works with <form action> | Requires JS on client | Works with any HTTP client |
| Caching / revalidation | Built-in revalidatePath / revalidateTag | Via TanStack Query | Manual cache headers |
| Bundle size | Zero client bundle for the action | Adds tRPC client + runtime | Zero (standard fetch) |
| Learning curve | Low if you know Next.js | Medium (routers, procedures, context) | Low |
Use Server Actions + Zod when you're building a Next.js app and most of your server interactions are form submissions and mutations. You get type safety without adding a framework.
Use tRPC when you need a typed data-fetching layer with subscriptions, batching, or complex query patterns. It's more infrastructure but more capable.
Use API Routes when you're building an API that non-Next.js clients will consume — mobile apps, third-party integrations, webhooks.
The Takeaway
The pattern is simple: define your contract as a Zod schema, pass it through a wrapper that handles validation and error formatting, and let TypeScript infer the types everywhere else. You write the schema once and get validation, types, and error messages from it. The wrapper is small enough to understand in five minutes and flexible enough to handle auth, rate limiting, and logging through middleware composition.
Start with the basic createAction wrapper. Add middleware when you need it. You'll find that most of the "API layer" code you used to write — try/catch blocks, input parsing, error formatting — just disappears.
Comments
No comments yet. Be the first to comment!