Siddhant Deval
Siddhant Deval
frontend5 min read

React Server Actions: POST Boundaries, Form Mutations & Progressive Enhancement

Server Actions expose a POST endpoint at the function level — they are a network boundary with CSRF exposure, typed FormData serialization, and a `useActionState` error-feedback contract that must be engineered deliberately, not annotated casually.

React Server Actions: POST Boundaries, Form Mutations & Progressive Enhancement

A Server Action is not a function. The moment you write 'use server' inside an async function, the bundler generates an HTTP POST endpoint with a machine-generated URL, a FormData serialization contract, and a SameSite + Origin header enforcement policy. You are not annotating a function — you are publishing an API route. Every production concern that applies to an explicit app/api/route.ts file applies equally here: input validation, error handling, optimistic rollback, and security. The difference is that the framework makes the surface area invisible, which is exactly why it requires explicit engineering discipline.


1. The Broken Pattern: "use server" as a Magic Annotation

This is the code that introduces most SDE-1s to Server Actions:

TYPESCRIPT
// ❌ Broken — async function with "use server" but no validation, no error handling
async function createComment(formData: FormData) {
  'use server'
  const content = formData.get('content')
  await db.comments.create({ data: { content } }) // content could be null, XSS payload, or 50,000 chars
}

export default function CommentForm() {
  return (
    <form action={createComment}>
      <textarea name="content" />
      <button type="submit">Post</button>
    </form>
  )
}

This code has four compounding problems:

  1. No type coercion: FormData.get('content') returns string | File | null. Passing this directly to a database query is an untyped write.
  2. No validation: No length limits, no content policy, no required-field check. The database receives whatever the browser sends.
  3. No error feedback: If the database throws, the user sees nothing. The form does not indicate failure.
  4. No CSRF reasoning: The developer is unaware that this function is an HTTP endpoint and has not thought about who can call it.

Server Actions look like local function calls but behave like API routes. The discipline required is the same.


2. Server Actions as POST Endpoints

2.1 What the Framework Generates

When Next.js builds a Server Action, it generates a POST endpoint at a URL determined by the action's module path and a hash of its name:

POST /_next/action/[hash]
Content-Type: multipart/form-data | application/x-www-form-urlencoded | application/json

# The request body is serialized FormData — exactly as a native HTML form POST

You can observe this in the browser's Network tab: every Server Action invocation is a POST request to a /_next/action/... URL. The response is a React Flight payload containing either the new server-rendered state or an error signal.

2.2 CSRF Protection

Next.js enforces two headers on every Server Action POST request:

Header Enforced Value Purpose
Origin Must match the server's origin Prevents cross-origin form submissions
SameSite (cookie) Lax or Strict on the session cookie Prevents cross-site request forgery

This means a malicious page on evil.com cannot submit a form to your Server Action with the victim's session cookie — the Origin header will not match and the request will be rejected. However, this protection requires:

  • Your session cookie has SameSite: Lax or Strict (not None)
  • You do not disable Next.js's built-in origin check

[!CAUTION] If you use headers().get('origin') to disable or bypass Next.js's CSRF check for cross-origin access, you void the security contract. Treat Server Actions like API routes: if you need cross-origin access, use an explicit API route with your own CORS and authentication policy.


3. FormData Extraction and Type-Safe Inputs

Raw FormData.get() returns string | File | null. To write a Server Action that handles real user input safely, you must coerce and validate before touching the database:

TYPESCRIPT
// ✅ Type-safe FormData extraction with Zod validation
import { z } from 'zod'
import { redirect } from 'next/navigation'

// Schema-first: define the shape before writing the action
const CommentSchema = z.object({
  content: z
    .string()
    .min(1, 'Comment cannot be empty')
    .max(1000, 'Comment must be under 1000 characters')
    .trim(),
  parentId: z.string().cuid().optional(),
})

export type CommentFormState = {
  errors?: {
    content?: string[]
    parentId?: string[]
  }
  message?: string
}

async function createComment(
  prevState: CommentFormState,
  formData: FormData,
): Promise<CommentFormState> {
  'use server'

  // Step 1: Extract raw values from FormData
  const rawData = {
    content: formData.get('content'),
    parentId: formData.get('parentId'),
  }

  // Step 2: Validate with Zod — safeParse does not throw, returns a result object
  const validated = CommentSchema.safeParse(rawData)

  if (!validated.success) {
    return {
      errors: validated.error.flatten().fieldErrors,
      message: 'Validation failed. Please check the form.',
    }
  }

  // Step 3: Act on validated data — the type is now { content: string; parentId?: string }
  try {
    await db.comments.create({
      data: {
        content: validated.data.content,
        parentId: validated.data.parentId ?? null,
        authorId: await getCurrentUserId(), // verified server-side — cannot be spoofed
      },
    })
  } catch (error) {
    return { message: 'Database error. Your comment could not be saved.' }
  }

  // Step 4: On success, revalidate the cache and redirect
  revalidateTag('comments')
  redirect(`/posts/${postId}`)
}
Pro Tip & Optimization

revalidateTag invalidates all cached data tagged with 'comments' — any fetch call or database query wrapped with { next: { tags: ['comments'] } } will be re-executed on the next request. This is more surgical than revalidatePath, which invalidates an entire route's cache.


4. useActionState for Validation Feedback

The createComment action above returns a CommentFormState — but how does the client form access this return value to show field errors? That is the job of useActionState.

Crucial Requirement

useActionState is the React 19 stable name — released as part of React 19.0.0 (December 2024). useFormState was the Canary/experimental name from react-dom and is now deprecated. Import useActionState from 'react', not from 'react-dom'.

TYPESCRIPT
// ✅ useActionState — connects form to Server Action return value
'use client'
import { useActionState } from 'react'
import { createComment, type CommentFormState } from './actions'

const initialState: CommentFormState = {}

export function CommentForm({ postId }: { postId: string }) {
  const [state, action, isPending] = useActionState(createComment, initialState)
  //     ^state: CommentFormState (return value from the Server Action)
  //             ^action: the enhanced form action (wraps createComment with state)
  //                      ^isPending: true while the Server Action is in-flight

  return (
    <form action={action}>
      <textarea
        name="content"
        aria-invalid={!!state.errors?.content}
        aria-describedby="content-error"
        disabled={isPending}
      />

      {/* Display server-returned validation errors */}
      {state.errors?.content && (
        <p id="content-error" role="alert" className="field-error">
          {state.errors.content[0]}
        </p>
      )}

      {state.message && (
        <p role="status" className="form-message">
          {state.message}
        </p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? 'Posting…' : 'Post Comment'}
      </button>
    </form>
  )
}

How useActionState Works

useActionState wraps the Server Action with a state management layer:

  1. On form submit, it calls the Server Action with (prevState, formData) — the previous state is always threaded in as the first argument
  2. While the action is in flight, isPending is true
  3. When the action returns, the return value becomes the new state
  4. The component re-renders with the new validation errors or success message

5. Progressive Enhancement

Server Actions work without JavaScript in the browser. This is the progressive enhancement contract: when JavaScript is disabled or fails to load, native HTML form submission falls back to a full-page POST — and the Server Action still executes correctly.

TYPESCRIPT
// ✅ Progressive enhancement — works with JS disabled
export function CommentForm() {
  return (
    <form action={createComment}> {/* Native HTML form action — works without JS */}
      <textarea name="content" required maxLength={1000} />
      <button type="submit">Post Comment</button>
    </form>
  )
}

// ❌ Broken progressive enhancement — requires JavaScript
export function CommentFormBroken() {
  const handleClick = async () => {
    // This is a JavaScript-only click handler — no native form POST fallback
    await createComment(new FormData())
  }
  return <button onClick={handleClick}>Post Comment</button>
}

The <form action={serverAction}> syntax is what enables the fallback. When JavaScript loads, React intercepts the submit event and upgrades it to a fetch-based POST with useActionState state management. When JavaScript does not load, the browser submits the form natively to the same Server Action endpoint.

Architectural Note

For progressive enhancement to work correctly with useActionState, your Server Action must accept (prevState, formData) — the two-argument signature. If your action only accepts formData, useActionState will still work with JavaScript enabled, but the native form POST fallback will not pass prevState.


6. useOptimistic for Immediate UI Feedback

For mutations where the user expects immediate visual feedback (adding a comment, toggling a like, reordering a list), useOptimistic provides optimistic state that is instantly applied on the client while the Server Action is in-flight, with automatic rollback on failure:

TYPESCRIPT
'use client'
import { useOptimistic, useActionState } from 'react'

interface Comment {
  id: string
  content: string
  author: string
  pending?: boolean
}

export function CommentThread({
  comments,
  postId,
}: {
  comments: Comment[]
  postId: string
}) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    // Reducer: how to derive optimistic state from current committed state
    (currentComments: Comment[], newComment: Comment) => [
      ...currentComments,
      { ...newComment, pending: true }, // Mark as pending — show dim in UI
    ],
  )

  const [state, action, isPending] = useActionState(createComment, {})

  async function handleSubmit(formData: FormData) {
    // Apply optimistic update immediately — before the Server Action completes
    addOptimisticComment({
      id: `optimistic-${Date.now()}`,
      content: formData.get('content') as string,
      author: 'You',
    })
    await action(formData) // Call the actual Server Action
    // If the action fails, React automatically reverts to the committed state
  }

  return (
    <div>
      <ul>
        {optimisticComments.map(comment => (
          <li key={comment.id} style={{ opacity: comment.pending ? 0.6 : 1 }}>
            <strong>{comment.author}</strong>: {comment.content}
            {comment.pending && <span> (posting…)</span>}
          </li>
        ))}
      </ul>
      <form action={handleSubmit}>
        <textarea name="content" />
        <button type="submit" disabled={isPending}>Post</button>
      </form>
    </div>
  )
}
Performance / Safety Warning

The optimistic state reducer must be a pure function — it must return a new array derived from currentComments without mutating it. On Server Action failure, React discards the optimistic state and re-renders with the original comments prop value. If your reducer mutates the committed state array, the rollback will render corrupted data.


7. Server Actions in Client Components — The Import Rule

A Server Action can be called from a Client Component, but where that action is defined determines how it can be imported:

TYPESCRIPT
// ✅ Correct — Server Action in a dedicated "use server" module
// app/actions/comments.ts
'use server' // Module-level directive — all exports are Server Actions

export async function createComment(prevState: CommentFormState, formData: FormData) {
  // ... validated data, DB write, revalidation
}

export async function deleteComment(commentId: string) {
  // ...
}
TYPESCRIPT
// ✅ Client Component imports from the "use server" module
'use client'
import { createComment } from '@/app/actions/comments' // ✅ Allowed — dedicated module

export function CommentForm() {
  const [state, action] = useActionState(createComment, {})
  return <form action={action}>...</form>
}
TYPESCRIPT
// ❌ Broken — Server Action defined inside a Server Component file
// app/posts/[id]/page.tsx (a Server Component file — no 'use client')

async function createComment(formData: FormData) {
  'use server' // Function-level directive
  // ...
}

// This action can be passed as a prop to a Client Component — but it cannot be imported
// by a Client Component from this file. A Client Component importing this file would
// attempt to import a Server Component module, which the bundler forbids.
Definition Location Can Pass as Prop to Client Can Import in Client File
'use server' module file ✅ ✅
Function-level 'use server' in a Server Component file ✅ ❌

The rule: extract Server Actions that Client Components need to import into a dedicated 'use server' module file. This keeps the module graph boundaries clean and prevents build errors.


Summary

Concept Rule
What a Server Action is An HTTP POST endpoint generated by the bundler. Treat it with the same security discipline as an explicit API route.
FormData extraction Always use FormData.get() + a validation library (Zod). FormData.get() returns string | File | null — never pass this directly to a database.
useActionState The React 19 stable API for connecting form state to Server Action return values. Import from 'react'. Accepts (prevState, formData) signature. useFormState is deprecated.
Progressive enhancement Use <form action={serverAction}> not onClick. The native form POST fallback to the Server Action endpoint works without JavaScript.
useOptimistic rollback The optimistic reducer must be pure. On Server Action failure, React reverts to the last committed state.
Import rule Extract Server Actions into a 'use server' module file if they need to be imported by Client Components. Function-level 'use server' in a Server Component file cannot be imported by Client Components.

What's Next

In Part 3, we leave mutations and cover Partial Prerendering — the rendering mode that combines a CDN-cached static shell with per-request streaming slots, making personalization and caching a per-component decision rather than a per-page one. Part 3: Partial Prerendering →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#React#Server Actions#useActionState#FormData#Next.js#Progressive Enhancement
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.