JSON in Next.js Server Actions: Input Validation, Return Values, and Error Handling
Last updated:
Server Actions are Next.js's way of letting a Client Component call a server function as if it were local — no fetch, no JSON.stringify, no manual response parsing. The framework handles the wire format, but the JSON-shaped contract between client and server still matters: what arguments serialize cleanly, what return values play nicely with useActionState, where validation belongs, and how to model errors so the UI can render them without a special branch. This guide walks through the JSON IO patterns: the wire format the platform actually uses, validating inputs with Zod (and zod-form-data when the input is FormData), the discriminated-union return envelope, how revalidation and redirects interact with what you return, and the call between Server Actions and Route Handlers for any given mutation.
Debugging a Server Action that returns an object the client cannot parse? Paste the logged response into Jsonic's JSON Validator — it surfaces the exact field that broke serialization, with line and column.
Validate response JSONWhat a Server Action sends and receives (wire format)
When you call a Server Action from a Client Component, the framework compiles the function to an encrypted action ID and the call becomes an HTTP POST to the same URL the user is on, with a special header (Next-Action) identifying which action to invoke. The body is not literally a JSON document — it uses the React Server Components action payload format, a richer serialization that supports types JSON cannot (Dates round-trip as Dates, not strings; Maps and Sets are preserved; BigInts work without conversion). For practical purposes, treat it as a superset of JSON.
The response comes back in the same format. Whatever your action returns — typically a plain object — arrives in the client as the same shape, with native types intact. That removes the JSON.stringify / JSON.parse round trip that Route Handlers force you into, but it also means you cannot ship anything genuinely non-serializable: functions, class instances with closure-captured private state, DOM nodes, React elements, or Symbols. Attempting to return one throws a serialization error at the boundary that surfaces in the Next.js dev overlay.
From a debugging perspective the action call shows up as a regular POST in DevTools with an opaque action ID. You can inspect the request body, but it is encoded — not readable JSON. For introspectable contracts at the network layer, you want a Route Handler. For end-to-end type safety with the lowest possible boilerplate, Server Actions win.
Input: FormData vs object args (serialization rules apply)
Server Actions take inputs two ways. The first is FormData — the browser-native form serialization, used when you bind an action to <form action={myAction}>. The second is plain object args — used when you call the action programmatically from a Client Component, typically through useTransition or a button click handler. Both are first-class.
// FormData input — bound to <form action>
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title')
const body = formData.get('body')
// ... validate, persist, return
}
// Object input — called programmatically
'use server'
export async function createPost(input: { title: string; body: string }) {
// input is already typed
// ... validate, persist, return
}FormData strengths: the form works without JavaScript (progressive enhancement), the browser handles encoding, file uploads come through the same API with no extra plumbing, and the action URL is the page URL — refreshing or sharing the page Just Works. FormData weakness: every field is a string (or File). Numbers, booleans, dates need conversion before they are useful.
Object args strengths: end-to-end TypeScript types, nested objects and arrays without flattening, native types preserved across the boundary. Weakness: requires JavaScript to submit, and you write the event-handler glue yourself.
The hybrid pattern that scales well: bind FormData for the form path, but parse it into a typed object immediately at the top of the action using zod-form-data. From that point on you work with typed data, and the action body looks identical to the object-arg version. See the JSON form handling guide for the cross-cutting patterns that apply to both.
Validation with Zod and zfd (zod-form-data)
Server Action input lives on a publicly callable endpoint. Any client — including a malicious one — can submit any shape they want. Validate everything. Zod is the standard, and zod-form-data (zfd) layers helpers on top of it for the FormData case.
// app/actions.ts
'use server'
import { z } from 'zod'
import { zfd } from 'zod-form-data'
const CreatePostSchema = zfd.formData({
title: zfd.text(z.string().min(1).max(200)),
body: zfd.text(z.string().min(10)),
publishedAt: zfd.text(z.coerce.date()).optional(),
tags: zfd.repeatable(z.array(z.string()).default([])),
isDraft: zfd.checkbox(),
views: zfd.numeric(z.number().int().nonnegative().default(0)),
})
export async function createPost(_prev: State, formData: FormData) {
const result = CreatePostSchema.safeParse(formData)
if (!result.success) {
return {
success: false as const,
error: { fields: result.error.flatten().fieldErrors },
}
}
// result.data is fully typed:
// { title: string; body: string; publishedAt?: Date; tags: string[]; isDraft: boolean; views: number }
const post = await db.post.create({ data: result.data })
return { success: true as const, data: post }
}The zfd helpers earn their keep on the conversion side. zfd.text coerces missing/empty fields to undefined so optional schemas behave. zfd.numeric parses strings into numbers before validating. zfd.checkbox turns the checkbox protocol (present means true, absent means false) into a proper boolean. zfd.repeatable collects all values for a repeated field name into an array — what you want for multi-select inputs.
For deeper coverage of schema design, refinements, and error message customisation, the Zod validation guide goes through the patterns that apply across Server Actions, Route Handlers, and client-side forms with the same schema.
Return value JSON shape: success/error pattern
The return value is what the client sees synchronously after the action resolves. Model it as a discriminated union so consumers narrow on a single boolean and get type-safe access to either the data or the error fields.
// Define once, reuse for every action
export type ActionResult<T> =
| { success: true; data: T }
| {
success: false
error: {
code: 'validation' | 'not_found' | 'unauthorized' | 'rate_limited' | 'conflict' | 'server'
message: string
fields?: Record<string, string[]>
}
}
// In an action
export async function createPost(
_prev: ActionResult<Post> | null,
formData: FormData,
): Promise<ActionResult<Post>> {
const parsed = CreatePostSchema.safeParse(formData)
if (!parsed.success) {
return {
success: false,
error: {
code: 'validation',
message: 'Some fields need attention.',
fields: parsed.error.flatten().fieldErrors,
},
}
}
const post = await db.post.create({ data: parsed.data })
return { success: true, data: post }
}Why a discriminated union beats throwing for expected failures: the client gets a value back synchronously, the form stays on the page with the user's input intact, and TypeScript narrows the error branch cleanly. Throwing in a Server Action propagates to the nearest error.tsx boundary — correct for genuinely unexpected failures, wrong for things you anticipated.
Keep the envelope shape stable across every action in the app. Pages and forms that consume actions then share the same rendering logic — one component renders error.fields below inputs, another renders error.message as a toast, and the success branch hands off to navigation or optimistic UI. See the Error handling guide for the cross-protocol version of this contract.
useActionState hook for client integration
useActionState (React 19, previously useFormState) wraps a Server Action and gives you the state, a bound action function, and a pending boolean — everything a form needs. Pair it with a Server Component that renders the form and a Client Component that handles the interactive bits.
// app/posts/new/page.tsx — Server Component
import { PostForm } from './post-form'
export default function NewPostPage() {
return <PostForm />
}
// app/posts/new/post-form.tsx — Client Component
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { createPost } from '@/app/actions'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving…' : 'Publish'}
</button>
)
}
export function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, null)
return (
<form action={formAction}>
<input name="title" required aria-invalid={!!state?.error?.fields?.title} />
{state?.error?.fields?.title?.map((m) => <p key={m}>{m}</p>)}
<textarea name="body" required aria-invalid={!!state?.error?.fields?.body} />
{state?.error?.fields?.body?.map((m) => <p key={m}>{m}</p>)}
<SubmitButton />
{state?.success && <p>Published — id {state.data.id}</p>}
</form>
)
}The hook's three return values: state (initialized from the second argument, updated to whatever the action returns on each invocation), formAction (a wrapped version of the action you bind to <form action>), and isPending (true while the action is in flight). useFormStatus from react-dom is the child-friendly version — any descendant of a form can read pending state without prop drilling.
State survives across submissions. If the user submits, gets a validation error, and submits again, the second invocation receives the previous state as its first argument (which is why the action signature starts with _prev). That pattern is useful for things like remembering a draft id across retries.
Revalidation: revalidatePath, revalidateTag, updateTag
Mutations in Server Actions need to tell the framework that cached data is stale. Three functions from next/cache handle this, and Next.js 16 adds a fourth for Cache Components.
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { updateTag } from 'next/cache' // Next.js 16 Cache Components
export async function publishPost(_prev: ActionResult<Post> | null, formData: FormData) {
const parsed = PublishSchema.safeParse(formData)
if (!parsed.success) return { success: false, error: /* ... */ }
const post = await db.post.update({
where: { id: parsed.data.id },
data: { publishedAt: new Date() },
})
// Path-level: any page rendered from /posts re-fetches on next visit
revalidatePath('/posts')
revalidatePath(`/posts/${post.slug}`)
// Tag-level: any fetch() with this tag will re-fetch on next visit
revalidateTag('posts-list')
revalidateTag(`post-${post.id}`)
// Next.js 16 Cache Components: update the in-render cache directly
updateTag(`post-${post.id}`, post)
return { success: true, data: post }
}revalidatePath takes a URL path (or pattern) and marks every cached render of that path as stale. The next request rebuilds. Useful when a mutation affects a single page or a small set of pages.
revalidateTag takes a tag string. Any fetch() call (or cached function) annotated with that tag is invalidated. Useful when the same data shows up on many pages — tag the data once, invalidate everywhere with one call.
updateTag (Next.js 16, Cache Components) goes a step further: it updates the tagged cache entry in place with the new value, so the next render uses the fresh data without a database round trip. Pair it with the use cache directive and cacheTag. The mental model is "invalidate plus prefill".
Revalidation runs after your action body but before the return value reaches the client. The client still gets the synchronous return value for optimistic UI — the revalidation flush is for the next navigation.
Error handling: thrown vs returned errors, redirects
Two error channels exist in a Server Action and they mean different things. Returned errors are part of the contract — the client gets a value and the form stays on the page. Thrown errors propagate to the nearest error.tsx boundary, swap the page to an error UI, and lose any form state.
'use server'
import { redirect, notFound } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export async function deletePost(
_prev: ActionResult<null> | null,
formData: FormData,
): Promise<ActionResult<null>> {
const id = formData.get('id')
if (typeof id !== 'string') {
// Expected failure: return the envelope
return { success: false, error: { code: 'validation', message: 'Missing id.' } }
}
const post = await db.post.findUnique({ where: { id } })
if (!post) notFound() // throws — page becomes 404
if (!(await canDelete(post))) {
// Expected failure: return the envelope
return { success: false, error: { code: 'unauthorized', message: 'Not your post.' } }
}
try {
await db.post.delete({ where: { id } })
} catch (e) {
// Unexpected: log and throw — error boundary handles it
console.error('delete failed', e)
throw new Error('Database error')
}
revalidatePath('/posts')
redirect('/posts') // throws a navigation signal — code below does not run
}Use returned errors for: validation failures, business-rule violations (insufficient balance, duplicate email, expired session), authorization failures where the user should see a message and try again. Use thrown errors for: infrastructure failures (database unreachable, third-party API down), invariant violations (the application reached a state that should be impossible). Use notFound() for: the requested resource genuinely does not exist and the page should become a 404.
redirect() and notFound() caveat: both work by throwing a special control-flow signal that the framework catches. A blanket try/catchwrapping your whole action body will swallow that signal and the navigation will not happen. Wrap only the operations that might genuinely throw (the database call), and let redirect and notFound bubble out cleanly.
Server Actions vs Route Handlers: when to choose which
Both can mutate data. The decisive question is who calls the endpoint and what contract they need.
| Concern | Server Actions | Route Handlers |
|---|---|---|
| Who calls it | Your own UI only | Anything — your UI, mobile apps, webhooks, partners |
| URL | Encrypted action ID (opaque) | Stable URL you choose (e.g. /api/posts) |
| Method | POST only | Any (GET, POST, PUT, DELETE, PATCH) |
| Status codes | Framework-controlled | You set them explicitly |
| Custom headers on response | Limited | Full control |
| Progressive enhancement | Yes (with FormData + <form action>) | Requires JavaScript to call |
| Argument types | FormData or object (rich types preserved) | Request body — JSON, FormData, or raw |
| Boilerplate to call from React | Minimal — call as function | fetch + JSON.stringify + parse + error handling |
| End-to-end TypeScript | Yes — argument and return types flow through | Manual — you maintain types on both sides |
| OpenAPI / discoverable contract | No | Yes |
Most apps end up using both. Server Actions handle internal forms — create post, update profile, submit comment — where the only caller is the app's own UI. Route Handlers handle anything with an external caller: webhooks from Stripe or GitHub, mobile-app backends, public APIs. See the Next.js JSON API guide for the Route-Handler-centric API patterns, and the RSC JSON guide for the read-side equivalent.
Key terms
- Server Action
- A function with the
'use server'directive that compiles to a POST endpoint accessible from Client Components without writing a fetch call. Introduced in Next.js 13, GA in Next.js 14 (October 2023), refined in 15 and 16. - RSC action payload
- The serialization format Next.js uses for Server Action arguments and return values. A superset of JSON that preserves Dates, Maps, Sets, BigInts, typed arrays, and FormData natively across the network boundary.
- useActionState
- A React 19 hook (previously named
useFormState) that wraps a Server Action and returns the current state, a bound action function for use with<form action>, and an isPending boolean. - useFormStatus
- A React 19 hook from
react-domthat reads the pending status of the ancestor form from any descendant Client Component, without prop drilling. - discriminated union return envelope
- The recommended return shape for Server Actions: a TypeScript union of
{ success: true; data }and{ success: false; error }that lets consumers narrow on a single boolean and access either branch type-safely. - revalidatePath / revalidateTag
- Functions from
next/cachethat mark cached renders or tagged fetches as stale. The next request rebuilds with fresh data. Called from inside a Server Action after a mutation. - updateTag
- A Next.js 16 Cache Components function that not only invalidates a tagged cache entry but writes the new value into the cache so the next render uses fresh data without a database round trip.
Frequently asked questions
Do Server Actions return JSON?
Server Actions return whatever serializable value you put after the return statement — the framework handles encoding over the wire automatically. The transport is not literally an application/json body the way a fetch call to a Route Handler is; under the hood Next.js uses a React Server Components action payload encoded with a binary-friendly format that supports more than JSON (Dates, BigInts, Maps, Sets, FormData, typed arrays). From your perspective the function returns a plain JavaScript object and the client receives a plain JavaScript object — the framework handles the encoding round-trip. Practically every team treats the return value as JSON-shaped — a small object with success and data fields, or success false plus error fields — because that shape composes cleanly with useActionState, optimistic updates, and error UI. Anything not directly serializable (functions, class instances with private fields, DOM nodes) will throw at the boundary.
How do I validate Server Action inputs?
Define a Zod schema, parse the incoming FormData (or object) inside the action, and branch on success. The pattern looks like: const result = schema.safeParse(rawInput); if (!result.success) return { success: false, error: result.error.flatten() }; then use result.data with full type safety. For FormData specifically, the zod-form-data package (zfd) gives you helpers that convert string-valued form fields into proper types — zfd.numeric(), zfd.checkbox(), zfd.repeatable() for multi-select fields. Always validate on the server side even when the client also validates — the action endpoint is publicly callable and clients can submit anything. Return validation errors in the same envelope shape as other errors so your UI does not need a separate branch for field-level failures. For deeper coverage of schema design see our Zod validation guide.
Can I pass a JavaScript object to a Server Action?
Yes — calling a Server Action from a Client Component lets you pass any serializable argument, including plain objects, arrays, Dates, Maps, Sets, BigInts, and typed arrays. The framework serializes the argument with the React Server Components payload format and reconstructs it on the server. Limitations: you cannot pass functions, class instances with private state, DOM nodes, React elements, or anything containing those. Symbols are not transmitted. If you pass a FormData object (the typical pattern for <form action={action}>), the action receives a FormData instance on the server. If you call the action from a button click or programmatic handler with a plain object, the action receives that object. Both styles are first-class — choose based on whether you want progressive enhancement (FormData, works without JS) or a richer typed payload (object args).
What's the difference between useActionState and useTransition?
useActionState (React 19, previously named useFormState) wraps a Server Action and gives you back three things: the current state (initialized from your second argument, updated after each invocation with the action's return value), a wrapped action function to bind to the form, and an isPending boolean. It is purpose-built for forms — state survives across submissions, you get pending without extra plumbing, and the wrapped action plays nicely with <form action>. useTransition is lower-level — it gives you isPending and startTransition, but you manage state yourself with useState. Choose useActionState when the action's return value is the new UI state (form errors, success messages, updated records). Choose useTransition when you are calling an action imperatively (button click outside a form, programmatic mutation) and the action's success is signalled by router revalidation rather than a returned payload.
How do I return errors from a Server Action?
Return them in the same envelope as success — typically { success: false, error: { code, message, fields? } } — rather than throwing. Thrown errors in a Server Action propagate to the nearest error.tsx boundary and trigger a full error UI, which is correct for genuinely unexpected failures (database down, third-party API outage) but wrong for expected failures like validation errors or business-rule violations (email already taken, insufficient balance). The returned-envelope pattern keeps the form on the page, lets useActionState surface field-level messages, and avoids losing the user's input. Reserve thrown errors for cases where the page itself should swap to an error state. For more on this split see our Error handling guide. Always type the envelope as a discriminated union so the consumer's TypeScript narrowing works on the success field.
Should I use Server Actions or Route Handlers for a form?
Server Actions for forms inside your Next.js app where progressive enhancement matters and the action is private to the UI. Route Handlers for endpoints that need to be called from external clients (mobile apps, third-party integrations, webhooks), or where you need explicit control over HTTP semantics like status codes, custom headers, or non-POST methods. The decisive question is who calls it. If only your own UI calls it, Server Actions reduce boilerplate dramatically — no fetch call, no JSON.stringify, no manual error parsing, no separate types for request and response. If something else needs to call it, you need a stable URL and a documented contract, which is what Route Handlers provide. See our Route Handlers guide for the API perspective and Next.js JSON API for the general pattern. The two approaches can coexist — many apps use Server Actions for forms and Route Handlers for webhooks.
How does revalidatePath interact with my action's return?
They run in sequence and both matter. revalidatePath (or revalidateTag, or updateTag in Next.js 16 Cache Components) marks cached data for re-fetch on the next request — your Server Components on that path will re-render with fresh data when the user navigates or the router refreshes. The action's return value is what the client receives synchronously, before any revalidation kicks in. Typical flow: action mutates the database, calls revalidatePath('/posts'), then returns { success: true, data: newPost }. The client gets the new post immediately for optimistic UI; on the next visit to /posts the cached page rebuilds with the mutation visible. If you only call revalidatePath and return nothing, the client gets undefined back and useActionState's state becomes undefined — usually not what you want. Always return something the UI can render against.
Can a Server Action redirect after success?
Yes — call redirect() from next/navigation inside the action, after any database writes and revalidation calls. The redirect throws a special control-flow error that the framework catches and converts to a navigation instruction for the client. Because of that, code after redirect() will not run, and the function's declared return type effectively becomes never on that branch. A common pattern: validate input, write to database, revalidatePath('/dashboard'), redirect('/dashboard/new-record'). If validation fails, return the error envelope instead — do not redirect on failure or you lose the form state and the user's input. redirect must be called outside any try/catch that catches all errors, because the catch will swallow the redirect signal — wrap only the operations that might genuinely throw, and let redirect bubble out cleanly. The same caveat applies to notFound().
Further reading and primary sources
- Next.js Docs — Server Actions and Mutations — Authoritative reference for the 'use server' directive, invocation patterns, and revalidation
- React Docs — useActionState — The hook for wiring a Server Action into a form with state, pending, and rebinding
- React Docs — useFormStatus — Read pending status from any descendant of a form without prop drilling
- zod-form-data on GitHub — Zod helpers for parsing FormData — typed numbers, checkboxes, repeated fields, file uploads
- Next.js Docs — revalidatePath and revalidateTag — How cache invalidation interacts with Server Actions and the App Router data cache