SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Feb 23, 2026·Security·SecureStartKit Team·Updated May 15, 2026

Server Actions + Zod in Next.js 16: Validate Every Input

Server Actions are public HTTP endpoints. Validate every payload with Zod before any database call. Patterns for Next.js 16 and Zod 4 with CVE context.

Summarize with AI

On this page

  • Table of contents
  • Why does every Server Action need Zod validation?
  • How do you set up Zod schemas that work on client and server?
  • What does a complete validate-then-authorize Server Action look like?
  • How do you wire useActionState to show field errors?
  • Should you add React Hook Form on top of useActionState?
  • What did Zod 4 change?
  • Five validation mistakes that ship broken security
  • What this means for your Next.js + Supabase forms

On this page

  • Table of contents
  • Why does every Server Action need Zod validation?
  • How do you set up Zod schemas that work on client and server?
  • What does a complete validate-then-authorize Server Action look like?
  • How do you wire useActionState to show field errors?
  • Should you add React Hook Form on top of useActionState?
  • What did Zod 4 change?
  • Five validation mistakes that ship broken security
  • What this means for your Next.js + Supabase forms

Server Actions plus Zod give Next.js a single trust boundary for form input: every payload is parsed against a typed schema before it reaches a database call, and the parsed object is the only thing that crosses into business logic. The combination matters because Server Actions are public HTTP endpoints with no built-in validation, and the May 2026 Next.js security release (CVE-2026-23870) showed that an unvalidated Server Function payload can be enough to drive a denial of service against any App Router deployment [3].

This guide covers the pattern as it works in Next.js 16 and Zod 4: shared schemas, safeParse with .flatten().fieldErrors, the React 19 useActionState hook, and the validate-then-authorize-then-query sequence that the backend-only data access pattern depends on.

TL;DR:

  • The boundary: every Server Action starts with schema.safeParse(input). The unparsed input never reaches a database query, a third-party API, or a redirect target.
  • The Zod 4 syntax shift: z.string().email() is now z.email(). The method form still works but is deprecated [4]. Migrate if you are pinning a fresh install.
  • The official Next.js warning: authentication and authorization must be checked inside each Server Action, even if the form only renders on an authenticated page [1]. Middleware is not enough.
  • The error shape that maps cleanly to UI: parsed.error.flatten().fieldErrors returns a Record<string, string[]> that pairs one-to-one with form field names. useActionState consumes this directly.
  • The mistake that breaks it: taking a user ID from the form payload instead of from the validated session. Zod validates structure; it does not validate identity. The two checks are independent.

Table of contents

  • Why does every Server Action need Zod validation?
  • How do you set up Zod schemas that work on client and server?
  • What does a complete validate-then-authorize Server Action look like?
  • How do you wire useActionState to show field errors?
  • Should you add React Hook Form on top of useActionState?
  • What did Zod 4 change?
  • Five validation mistakes that ship broken security
  • What this means for your Next.js + Supabase forms

Why does every Server Action need Zod validation?

A Server Action is a function the browser can call with any JSON payload, at any rate, with any authentication state. Next.js gives you Origin-vs-Host CSRF protection by default and forwards cookie sessions to the action [1]. It does not give you input validation. That is your job.

Three concrete reasons the validation step is non-negotiable in 2026:

CVE-2026-23870 (May 2026, high severity). A specially crafted HTTP request to any App Router Server Function endpoint can trigger excessive CPU consumption during deserialization. The vulnerability sits in the React Server Components serialization layer and ships in every default Next.js App Router install. Vercel patched it in Next.js 15.5.18 and 16.2.6 alongside twelve other advisories in the same release [3]. The framework patch closes the specific path, but Zod-on-every-input limits the broader class: any payload that does not match the schema gets rejected before deserialization completes downstream business logic.

Authorization bypass via tampered identifiers. The May 2026 release also patched CVE-2026-44574, a middleware-bypass flaw where query parameter injection altered which dynamic route the page handler resolved against [3]. The lesson generalizes: anything the browser can send, the browser can also tamper. A Server Action that reads a userId from formData and uses it as a query predicate is the same mistake at a different layer. The closely related trap is over-posting: spreading the request body into a database write so a user can set columns the form never showed, like role or user_id (see mass assignment in Server Actions).

The official Next.js documentation makes the rule explicit. From the Forms guide: "Always verify authentication and authorization inside each Server Action, even if the form is only rendered on an authenticated page" [1]. Validation comes first because it shapes the input; the auth check comes immediately after because validation does not look at identity. Both checks must run, in that order, before any privileged operation. And both must fail closed: when safeParse or the auth check throws, the action has to deny, not fall through. What failing closed looks like across redirects, catch blocks, and half-finished mutations is its own pattern, the fail-closed treatment of OWASP A10.

The implication for code: a Server Action that begins with raw formData.get('email') going straight into a database query is an unfixed bug. The post-validation parsed.data object is the only safe input. Anything not in the schema does not exist as far as your action is concerned.

How do you set up Zod schemas that work on client and server?

Put schemas in lib/schemas/, one file per domain. Each file exports the schema and the inferred TypeScript type. The same file is imported by both the Server Action and the React component that submits to it, so the field names, validation rules, and types stay synchronized.

// lib/schemas/auth.ts
import { z } from 'zod'

export const loginSchema = z.object({
  email: z.email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

export const signupSchema = z.object({
  email: z.email('Invalid email address'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .max(128, 'Password is too long'),
  fullName: z.string().min(2, 'Name must be at least 2 characters').max(80),
})

export type LoginInput = z.infer<typeof loginSchema>
export type SignupInput = z.infer<typeof signupSchema>

A few details that matter. The z.email() call is the Zod 4 top-level form; if you are still on Zod 3, write z.string().email() until you migrate. The .max() calls on string fields are not cosmetic. They are upper bounds that protect deserialization from oversized payloads, which is exactly the class of input the CVE-2026-23870 advisory warned about [3]. A password field that accepts unbounded length is a DoS vector before it is anything else.

The z.infer helper builds the TypeScript type from the schema [4]. The type and the runtime validator stay in lockstep. If you remove a field from the schema, the type no longer carries it, and every call site that referenced the field breaks at compile time. That is the property you want.

If you already have a sample JSON response or an existing TypeScript type you need to validate against, the free JSON to Zod converter scaffolds the schema with format detection (email, URL, UUID, ISO date) so you do not type each constraint by hand.

What does a complete validate-then-authorize Server Action look like?

The pattern is three steps in fixed order: validate input with Zod, identify the caller from the session, perform the privileged operation through an admin client. Skip any step and you have a broken action.

Here is the full shape of an action that creates a Stripe checkout session, taken from the SecureStartKit codebase.

// actions/billing.ts
'use server'

import { z } from 'zod'
import { redirect } from 'next/navigation'
import { createAdminClient, getUser } from '@/lib/supabase/server'
import { getStripe } from '@/lib/stripe/client'

const checkoutSchema = z.object({
  priceId: z.string().min(1).max(64),
  productName: z.string().max(120).optional(),
  locale: z.enum(['en', 'nl']).optional(),
})

export async function createCheckoutSession(
  rawInput: unknown
) {
  // 1. Validate input. Anything not in the schema is rejected.
  const parsed = checkoutSchema.safeParse(rawInput)
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors }
  }

  // 2. Identify the caller from the session cookie, never from input.
  const user = await getUser()
  if (!user) {
    redirect('/login?next=' + encodeURIComponent('/#pricing'))
  }

  // 3. Privileged work runs through the admin client.
  const admin = createAdminClient()
  const { data: customer } = await admin
    .from('customers')
    .select('stripe_customer_id')
    .eq('id', user.id) // user.id from validated session, never from parsed.data
    .single()

  // ... checkout creation continues with parsed.data.priceId
}

Five details deserve attention because each one is a place a real codebase has shipped a bug.

rawInput: unknown, not LoginInput. Typing the parameter as the inferred type implies the data is already valid, which it is not. Type the parameter as unknown (or a Server-Action-specific wrapper like FormData) so the compiler forces you to validate before you can read fields.

safeParse, not parse. Both validate, but parse throws on failure. A thrown validation error in a Server Action becomes an unhandled exception that Next.js renders generically. safeParse returns a discriminated union and lets you return structured errors to the client UI [4].

parsed.error.flatten().fieldErrors. This shape pairs cleanly with form field names. Each key is a field name; the value is a string[] of error messages for that field. The React component consuming the action state can render errors next to the right input without any mapping logic [1].

user.id from the session, not from parsed.data. Even after Zod validates that userId is a string, the value the browser sent is still the browser's value. A user could legitimately submit a checkout form with someone else's userId typed in. The validated session, read via getUser() from the Supabase server client, is the only trustworthy source of identity [2]. This is the rule that the backend-only data access pattern makes architectural.

Admin client only after both checks pass. The service_role client bypasses Row Level Security entirely. Calling it before validating input or identity is what turned the Lovable breach into a 170-app exposure event [6]. The three-step order is the architectural defense.

This same order applies to every mutation in the app: signup, password reset, profile update, file upload, webhook handler. The schemas differ; the structure does not. If you want to validate this discipline systematically before a deploy, the free SaaS security checklist tool walks the validation, authorization, and database-access checks in order.

How do you wire useActionState to show field errors?

React 19 ships useActionState as the stable hook for connecting a form to a Server Action and rendering returned state. The hook gives you three things: the current state object (whatever your action returns), an action function bound to a form, and a pending boolean for loading UI [1].

When you use useActionState, the Server Action signature changes: the first parameter is the previous state, the second is the FormData or input.

// actions/auth.ts
'use server'

import { signupSchema } from '@/lib/schemas/auth'

type SignupState = {
  fieldErrors?: Record<string, string[]>
  formError?: string
  success?: boolean
}

export async function signup(
  _prevState: SignupState,
  formData: FormData
): Promise<SignupState> {
  const parsed = signupSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
    fullName: formData.get('fullName'),
  })

  if (!parsed.success) {
    return { fieldErrors: parsed.error.flatten().fieldErrors }
  }

  const user = await getUser()
  // ... the rest of the signup flow

  return { success: true }
}

The client component reads the state object and renders errors next to each field.

// components/SignupForm.tsx
'use client'

import { useActionState } from 'react'
import { signup } from '@/actions/auth'

const initialState = { fieldErrors: {} }

export function SignupForm() {
  const [state, formAction, pending] = useActionState(signup, initialState)

  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />
      {state.fieldErrors?.email && (
        <p role="alert">{state.fieldErrors.email[0]}</p>
      )}

      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" required />
      {state.fieldErrors?.password && (
        <p role="alert">{state.fieldErrors.password[0]}</p>
      )}

      <label htmlFor="fullName">Full name</label>
      <input id="fullName" name="fullName" type="text" required />
      {state.fieldErrors?.fullName && (
        <p role="alert">{state.fieldErrors.fullName[0]}</p>
      )}

      {state.formError && <p role="alert">{state.formError}</p>}

      <button type="submit" disabled={pending}>
        {pending ? 'Creating account...' : 'Sign up'}
      </button>
    </form>
  )
}

Three things make this minimal version work well. The name attributes on the inputs match the schema field names exactly, so formData.get('email') and state.fieldErrors.email line up without translation. The role="alert" element ensures screen readers announce errors when they appear. The pending boolean is read directly from useActionState, not from a separate request state; this avoids the double-loading-state bug where the button enables before the action returns.

For Server Actions called outside a form (button handlers, programmatic submissions), useFormStatus is the alternative for pending UI [1]. It only works inside a <form> ancestor, which is why useActionState is the more flexible pattern for SaaS apps with mixed form and non-form mutations.

Building this from scratch on a new SaaS?

SecureStartKit ships every pattern in this post out of the box: backend-only data access, Zod on every Server Action, RLS deny-all, signed Stripe webhooks with idempotency dedup. One purchase, lifetime updates.

See what's included →Live demo

Should you add React Hook Form on top of useActionState?

React Hook Form (RHF) is worth adding when the form is large, has dependent fields, or needs onChange or onBlur validation rather than only on-submit validation. For a four-field signup form, plain useActionState is enough. For a multi-step billing settings form with conditional fields, RHF's useFormContext and watch become valuable.

The integration uses @hookform/resolvers/zod to plug the Zod schema into RHF's validation. As of March 2026, @hookform/resolvers v4+ ships native Zod 4 support [4]; if you are still on Zod 3, pin @hookform/resolvers v3.x.

'use client'

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useActionState } from 'react'
import { signupSchema, type SignupInput } from '@/lib/schemas/auth'
import { signup } from '@/actions/auth'

export function SignupFormRHF() {
  const [serverState, formAction, pending] = useActionState(signup, {})

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignupInput>({
    resolver: zodResolver(signupSchema),
    mode: 'onBlur',
  })

  return (
    <form action={formAction} onSubmit={handleSubmit(() => {})}>
      <input {...register('email')} type="email" />
      {errors.email && <p role="alert">{errors.email.message}</p>}

      <input {...register('password')} type="password" />
      {errors.password && <p role="alert">{errors.password.message}</p>}

      <input {...register('fullName')} type="text" />
      {errors.fullName && <p role="alert">{errors.fullName.message}</p>}

      <button type="submit" disabled={pending}>Sign up</button>
    </form>
  )
}

Two things to keep in mind. RHF runs the same Zod schema in the browser; it does not replace server-side validation. The Server Action still runs safeParse because the browser can be bypassed with curl. RHF is a UX improvement, not a security one. And the mode: 'onBlur' setting matters: with mode: 'onChange', every keystroke triggers re-validation, which becomes expensive once you add async refinements like uniqueness checks.

Use plain useActionState for forms with under five fields and no inter-field dependencies. Add RHF when the form needs onChange or onBlur validation, when you want optimistic field-level error clearing, or when the form spans multiple components and needs useFormContext.

What did Zod 4 change?

Zod 4 shipped in 2025 with two changes that touch every form schema you write: top-level format functions and significant performance improvements. The migration is mechanical, but skipping it leaves deprecation warnings on every schema file.

Top-level format functions replace string chains. z.string().email() becomes z.email(). The same applies to z.url(), z.uuid(), z.iso.date(), z.iso.datetime(), and z.ipv4(). The old method-chain forms still work; they are deprecated and slated for removal in Zod 5 [4]. Top-level forms are also more tree-shakable: bundlers can drop the formats you do not use.

safeParse returns the same shape it did in Zod 3. Success returns { success: true, data }; failure returns { success: false, error }. The .flatten().fieldErrors accessor still works exactly as the Next.js docs example shows [1]. If you have working parsed.error.flatten() code, the upgrade does not break it.

Performance is meaningfully better. Zod 4 ships 14x faster string parsing, 7x faster array parsing, and a 2.3x smaller core bundle compared to Zod 3 [4]. For a Server Action that runs Zod on every request, this is not a micro-optimization; it changes the cost of validation against high-throughput endpoints (webhook receivers, public APIs) by enough to matter on a Vercel function bill.

Ecosystem compatibility is current. As of March 2026, @hookform/resolvers v4+, tRPC v11+, @tanstack/react-form, and Conform all support Zod 4 natively. The transitional pain is behind us; new projects should default to Zod 4, and existing projects should run the codemod (npx @zod/codemod migrate) when convenient.

The one nuance: the Next.js official forms guide example still uses z.string({ invalid_type_error: 'Invalid Email' }) in places [1]. That is Zod 3 syntax. Both work in Zod 4 with deprecation warnings. The replacement in Zod 4 is to pass the error message as a second argument: z.string('Invalid email').

Five validation mistakes that ship broken security

Each of these is a pattern that has caused a real bug in a real production codebase. They all type-check.

1. Forgetting to validate at all because middleware "already checks the user."

Middleware authenticates the request and forwards it; it does not validate the payload. CVE-2026-44574 was a middleware bypass via dynamic route parameter injection [3], which proves the general rule: middleware is a guard, not a sanitizer. Every Server Action runs its own safeParse regardless of what middleware decided about the route. Validation checks structure; when the validated value is then rendered as markup, render user HTML safely after validation is the next boundary.

2. Using parse instead of safeParse and relying on Next.js to render the error.

parse throws on validation failure. Next.js catches the throw and renders an error boundary, which surfaces a generic message to the user and a stack trace to the server logs. Neither is what you want. safeParse lets the action return structured errors that the form's useActionState consumes directly.

3. Trusting parsed.data for fields that should come from the session.

If a form posts a userId and the Server Action reads it from parsed.data.userId, the action can be called by a user to act on someone else's account. Zod validates that the field is a string. It does not validate that the string matches the logged-in user. The user ID comes from getUser() or getClaims() on the Supabase server client, never from the payload, even after validation [2]. The same rule applies to organizationId, teamId, and any other tenant identifier in a multi-tenant app.

4. Skipping .max() on string fields.

A schema that accepts z.string() without .max() accepts a 50 MB string. The Server Action deserializes the payload before your validation runs, and a 50 MB payload during deserialization is the CVE-2026-23870 attack class [3]. Every user-controlled string in a schema needs an upper bound. A reasonable default: .max(2048) for free-text fields, .max(120) for names and titles, .max(320) for emails (the RFC 5321 maximum).

5. Validating the form payload but skipping URL parameters and webhook bodies.

The same Zod schemas apply to anything crossing a trust boundary. The searchParams object in a Server Component is browser-controlled. A webhook payload from Stripe or Resend is verified by signature, but the contents inside the signed envelope can still be malformed or malicious if the provider is compromised. Validate everything: forms, URL parameters, webhook bodies, third-party API responses. For Route Handlers specifically, see the five Zod failure modes specific to Route Handlers (FormData coercion, z.coerce.boolean traps, raw-body conflicts with webhook signature verification, the missing CSRF check, identity from searchParams). For the broader hardening surface, see the Next.js security hardening checklist.

What this means for your Next.js + Supabase forms

The schema is the contract. Every Server Action begins with safeParse, every field has an upper bound, every privileged identifier comes from the validated session and not from the payload. With that boundary in place, the rest of the application gets simpler: the admin client trusts parsed.data, the revalidatePath calls run with confidence, the error UI maps cleanly onto field names via fieldErrors. The validation step is not paperwork. It is the trust boundary that lets the rest of the architecture be tight.

This is the same architectural posture as the backend-only data access pattern: keep the database away from the browser, keep the privileged operations behind validation and identity checks. Zod is the input half of that boundary; Supabase Auth and the admin client are the output half.

If you want this pre-wired (Zod schemas for auth, billing, and admin actions; a getUser helper that calls getClaims(); admin client gated behind import 'server-only'; the Supabase RLS deny-all defaults on every table; a pre-launch security checklist that audits every action), that is what SecureStartKit is. The site you are reading uses these exact patterns for every form on the page.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. How to create forms with Server Actions— nextjs.org
  2. Guides: Authentication— nextjs.org
  3. Next.js May 2026 security release— vercel.com
  4. Zod v4 release notes— zod.dev
  5. useActionState— react.dev
  6. SupaPwn: Hacking Our Way into Lovable's Office and Helping Secure Supabase— hacktron.ai

Related Posts

Jun 8, 2026·Security

5 Route Handler Zod Failure Modes in Next.js [2026]

5 Zod validation failure modes in Next.js Route Handlers: FormData coercion, coerce.boolean traps, body conflicts, missing CSRF, identity from URL.

Jun 12, 2026·Security

Next.js Errors That Fail Open: The OWASP A10 Fix [2026]

A caught redirect() or a swallowed auth check makes Next.js fail open. Where error handling grants access by accident, and the fail-closed fix.

Jun 5, 2026·Security

5 Production Rate-Limit Failure Modes in Next.js [2026]

Five production rate-limit failure modes for Next.js Server Actions: XFF off Vercel, fixed-window burst, distributed IPs, missing await, billing.