The useActionState post covers the hook API.
https://monalisadas-knowme.vercel.app/blog/react-19-useactionstate-from-usestate-chaos-to-server-action-clarity
This one covers what happens the moment you deploy: unsigned POST endpoints your scanner flags, CDN layers that start caching action responses, and error surfaces that don't map cleanly to client-side mutation patterns.
The useActionState demo is clean. A form submits, the server runs, the state updates. No fetch, no JSON, no loading flags scattered across three useState calls. I shipped it to staging, watched it work, and pushed to production.
Then three things happened in the first week. Our security scanner flagged the action endpoint as an unsigned POST without CSRF protection. The CDN started serving stale POST responses to users. And when the database went down, the error surface on the client was wrong — it showed a network error instead of the structured error I'd returned from the server. None of these are bugs in the API. They're gaps between the demo and the deployment reality.
The CSRF problem — and why Next.js mostly solves it for you
Server actions are HTTP POST requests under the hood. The browser makes them, which means any other origin can make them too — standard CSRF territory. Next.js 14+ generates a random action ID per deployment that's embedded in the client bundle and required in the request header. An attacker who can't read your bundle can't forge a valid action request.
That covers the framework-managed case. Where it breaks down is when you expose the action outside the framework — a native fetch call to the same endpoint, a third-party webhook hitting your action URL, or a reverse proxy that strips the action headers. I've seen all three in production.
// What Next.js sends on every server action request:
// Next-Action: <action-id-from-bundle>
// Content-Type: multipart/form-data
// If you're calling from fetch() directly, you must replicate this:
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Next-Action': ACTION_ID, // this is NOT stable across deployments
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ email }),
})
// Don't do this. If you need a fetch-based API, use a Route Handler instead.
The fix I settled on: treat server actions as internal framework calls only. If anything outside the Next.js client needs to hit that logic — a mobile app, a webhook, a cron — I extract it into a plain async function and call it from a Route Handler. The action calls the same function. Two entry points, one implementation, no shared CSRF surface.
// lib/contact.ts — the actual logic, no framework dependency
export async function processContactSubmission(email: string, message: string) {
if (!email || !isValidEmail(email)) throw new Error('Invalid email')
await db.contacts.insert({ email, message, createdAt: new Date() })
}
// app/actions.ts — server action entry point (Next.js clients)
'use server'
import { processContactSubmission } from '@/lib/contact'
type State = { ok: boolean; error: string | null }
export async function submitContactAction(
_prev: State,
formData: FormData
): Promise<State> {
try {
await processContactSubmission(
formData.get('email') as string,
formData.get('message') as string
)
return { ok: true, error: null }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Failed' }
}
}
// app/api/contact/route.ts — Route Handler entry point (external callers)
import { processContactSubmission } from '@/lib/contact'
export async function POST(req: Request) {
const { email, message } = await req.json()
try {
await processContactSubmission(email, message)
return Response.json({ ok: true })
} catch (err) {
return Response.json({ ok: false, error: (err as Error).message }, { status: 400 })
}
}
Edge caching will cache your POST responses if you're not careful
POST requests aren't supposed to be cached. The HTTP spec says so. Your CDN docs say so. But CDNs have custom rules, and some of them treat action endpoints differently when they see the same URL returning a 200 with a cacheable body.
I hit this on Vercel with a custom edge config that had aggressive caching rules for specific routes. The contact form started returning the previous submission's success state to a different user. That's not a framework bug — it's a misconfigured CDN rule treating a POST as if it were a GET.
// app/actions.ts
'use server'
import { headers } from 'next/headers'
type State = { ok: boolean; error: string | null }
export async function submitContactAction(
_prev: State,
formData: FormData
): Promise<State> {
// Force no-store on the response so no intermediate cache layer
// can treat this as cacheable — even if CDN rules get misconfigured
const headersList = headers()
// Reading headers() opts the route out of Next.js static caching.
// This is a Next.js-level safeguard, not a CDN header.
try {
await processContactSubmission(
formData.get('email') as string,
formData.get('message') as string
)
return { ok: true, error: null }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Failed' }
}
}
For the CDN layer itself, add an explicit Cache-Control header on any route that handles actions, and verify your edge config doesn't override it. On Vercel: check vercel.json for headers rules that match your action path. On Cloudflare: check cache rules. The framework can't protect you from config that runs before the request reaches it.
// vercel.json — explicit no-cache for action routes
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "no-store"
}
],
"has": [
{
"type": "header",
"key": "Next-Action"
}
]
}
]
}
Failure modes that don't map to client-side mutation patterns
With React Query or SWR, a mutation failure is a thrown error that the library catches and puts into an error state. The client knows exactly what to do: show the error, let the user retry. Server actions have three different failure modes that need different handling, and useActionState only gives you one surface to put them all.
The three modes I've hit in production:
The action returned a structured error (your code ran, validation failed, DB constraint hit). This is the happy-path failure — the server ran, understood the problem, and told you about it. Show the error message. Don't retry automatically.
The action threw an uncaught exception (server crashed, DB connection dropped, timeout). Next.js catches this, but the error doesn't reach useActionState's state — instead it propagates to the nearest error boundary. If you don't have one wrapping your form, the whole page blows up.
The network failed before the action ran (user went offline, DNS resolution failed, Vercel cold-start timeout). The form's isPending never resolves. The user sees a spinner forever.
// Handle all three in the form component
'use client'
import { useActionState, startTransition } from 'react'
import { useEffect, useRef } from 'react'
import { submitContactAction } from './actions'
type State = { ok: boolean; error: string | null; attempt: number }
const initial: State = { ok: false, error: null, attempt: 0 }
function ContactForm() {
const [state, action, isPending] = useActionState(submitContactAction, initial)
const formRef = useRef<HTMLFormElement>(null)
// Detect mode 3 (network timeout) — abort spinner after 15s
const pendingTimer = useRef<ReturnType<typeof setTimeout>>()
const [timedOut, setTimedOut] = useState(false)
useEffect(() => {
if (isPending) {
setTimedOut(false)
pendingTimer.current = setTimeout(() => setTimedOut(true), 15_000)
} else {
clearTimeout(pendingTimer.current)
}
return () => clearTimeout(pendingTimer.current)
}, [isPending])
return (
// Mode 2 (uncaught throw) — error boundary one level up handles this.
// Don't try to catch it here; the action's state won't update.
<form ref={formRef} action={action}>
<input name="email" type="email" required />
<input name="message" required />
<button type="submit" disabled={isPending && !timedOut}>
{timedOut ? 'Taking too long — try again' : isPending ? 'Sending…' : 'Send'}
</button>
{/* Mode 1 — structured error from the action */}
{state.error && !isPending && (
<p role="alert" className="error">{state.error}</p>
)}
{state.ok && <p>Done.</p>}
</form>
)
}
The error boundary is not optional. Wrap every form that uses a server action with one — or use Next.js's error.tsx at the route segment level. An uncaught throw from a server action surfaces as a React render error, not as a value in state. Without the boundary, the form takes down the page.
// app/contact/error.tsx — Next.js route-level error boundary
'use client'
export default function ContactError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<p>Something went wrong on our end. Your message wasn't sent.</p>
<button onClick={reset}>Try again</button>
{/* In dev, error.message. In prod, digest is a hash — log it, don't show it */}
{process.env.NODE_ENV === 'development' && (
<pre>{error.message}</pre>
)}
</div>
)
}
One more: action state survives navigation
useActionState state persists across re-renders of the same component instance, but resets when the component unmounts and remounts. In a Next.js app with soft navigation, that means: the user submits the form, navigates away, hits the browser back button, and the form is blank — the success/error state is gone. That's usually the right behavior, but if you need the state to survive navigation (a multi-page form, a confirmation you want to show on a different route), you need to lift it into a server component via a redirect or encode it in a URL search param.
// app/actions.ts — redirect on success to pass state via URL
'use server'
import { redirect } from 'next/navigation'
export async function submitContactAction(
_prev: { error: string | null },
formData: FormData
): Promise<{ error: string | null }> {
try {
await processContactSubmission(
formData.get('email') as string,
formData.get('message') as string
)
// State that must survive navigation goes in the URL, not in useActionState
redirect('/contact?sent=true')
} catch (err) {
// Structured errors come back as state — they don't need to survive navigation
return { error: err instanceof Error ? err.message : 'Failed' }
}
}
The production checklist
Before you deploy a form using server actions: extract shared logic into a plain function so you have one implementation behind two entry points (action + route handler). Wrap every action-backed form in an error boundary. Verify your CDN config doesn't cache POST responses — check for header rules that match your action paths. Add a client-side timeout to detect mode-3 failures. And if state needs to outlive a navigation, use the URL, not the action's return value.
The API is good. The defaults are right for most use cases. These are just the gaps between a demo that runs on localhost and a form that handles real failure conditions.
Top comments (0)