Skip to content
Web

Type-Safe Server Actions in Next.js App Router with Zod Validation

30 June 20266 min read0 views
Type-Safe Server Actions in Next.js App Router with Zod Validation
How to write robust, fully type-safe Next.js Server Actions using Zod schemas to validate incoming form data and protect against malformed input.

Why Server Actions Need Validation

Next.js Server Actions run on the server and can directly write to your database. Without input validation, malicious or malformed form submissions can corrupt your data or trigger unhandled exceptions. Zod gives you a schema-first approach: define the expected shape once, and get automatic runtime validation plus TypeScript inference for free.

Setting Up a Validated Server Action

"use server";

import { z } from "zod";
import { createAdminClient } from "@/utils/supabase/server";
import { revalidatePath } from "next/cache";

// Define schema once — TypeScript type is inferred automatically
const CreateProjectSchema = z.object({
  name: z.string().min(2).max(120),
  slug: z.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
  tagline: z.string().min(10).max(240),
  category: z.enum(["IoT", "AI", "Web", "Android", "Embedded", "Other"]),
  featured: z.coerce.boolean().default(false),
});

export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;

export async function createProject(formData: FormData) {
  // 1. Validate input — throws if invalid
  const parsed = CreateProjectSchema.safeParse({
    name: formData.get("name"),
    slug: formData.get("slug"),
    tagline: formData.get("tagline"),
    category: formData.get("category"),
    featured: formData.get("featured"),
  });

  if (!parsed.success) {
    // Return structured errors to the client
    return { error: parsed.error.flatten().fieldErrors };
  }

  // 2. Proceed with validated, typed data
  const adminClient = createAdminClient();
  const { error } = await adminClient
    .from("projects")
    .insert(parsed.data);

  if (error) return { error: { _form: [error.message] } };

  revalidatePath("/projects");
  return { success: true };
}

Consuming Errors in the Client Component

"use client";
import { useActionState } from "react";
import { createProject } from "./actions";

export function NewProjectForm() {
  const [state, action] = useActionState(createProject, null);

  return (
    <form action={action}>
      <input name="name" />
      {state?.error?.name && (
        <p className="text-red-500">{state.error.name[0]}</p>
      )}
      <button type="submit">Create</button>
    </form>
  );
}

Key Patterns

  • z.coerce: Use z.coerce.boolean() and z.coerce.number() for FormData values, which are always strings.
  • safeParse vs parse: safeParse returns a result object instead of throwing, which is safer inside Server Actions where exceptions show as opaque 500 errors.
  • flatten().fieldErrors: Returns per-field error arrays that map directly to form field names.

Building a custom Next.js admin portal? Let's talk →

Frequently Asked Questions

Q:Can I reuse Zod schemas between server and client components?

Yes. Define schemas in a shared file (e.g. lib/schemas.ts) and import them in both Server Actions and client-side React Hook Form configurations.

Q:How do I handle file uploads in Server Actions with Zod?

Zod does not validate File objects natively. Use z.instanceof(File) or check file.size and file.type manually after extracting the file from FormData, then upload to storage separately.

Working on something similar?

Let's collaborate to design custom PCB schematics, write deterministic FreeRTOS threads, or configure secure Next.js databases.

Let's talk →