Skip to content
digital garden
Back to Blog

Building a Type-Safe API Layer with Server Actions and Zod

11 min read
nextjstypescriptvalidationapi-design

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:

  1. No input validation. formData.get() returns FormDataEntryValue | null. Casting to string doesn't validate anything — it just lies to TypeScript. If budget is "abc", you're inserting NaN into your database.

  2. No consistent error handling. If db.project.create throws, what does the client get? An unhandled rejection. No structured error. No way to display a meaningful message.

  3. No type inference. The caller has no idea what this function actually expects. The types are FormData in, unknown out. 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 string casts)
  • 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 + ZodtRPCAPI Routes
Type safetySchema-inferred, end-to-endFull end-to-end via routerManual, requires shared types
Setup cost~35 lines for the wrapperRouter setup, client providerPer-route boilerplate
Best forNext.js apps with forms and mutationsFull-stack apps needing typed queries + mutationsPublic APIs, webhooks, third-party integrations
Progressive enhancementWorks with <form action>Requires JS on clientWorks with any HTTP client
Caching / revalidationBuilt-in revalidatePath / revalidateTagVia TanStack QueryManual cache headers
Bundle sizeZero client bundle for the actionAdds tRPC client + runtimeZero (standard fetch)
Learning curveLow if you know Next.jsMedium (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!