Next.js Server Actions — Forms, Mutations, and Progressive Enhancement

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Server Actions are async functions that run on the server but can be called directly from Client Components. They were stabilized in Next.js 14 and eliminate the need to create API route handlers for most form submissions and data mutations. Marked with 'use server', they execute server-side code — database writes, email sends, auth checks — triggered by a simple function call from the client.

Why This Matters

Before Server Actions, a typical form submission required: a Client Component with state, a fetch('/api/submit') call, an API route handler, and manual cache invalidation. Server Actions collapse this into a single async function. The result is less code, fewer network roundtrips, and progressive enhancement out of the box — forms work even without JavaScript.

Server Actions also close a security gap: no API endpoint means no attack surface. The function only runs when called from your own application, and the code never ships to the browser.

Creating Server Actions

Server Actions are defined with the 'use server' directive — either at the top of a dedicated file or inline inside a Server Component:

// app/actions.ts — shared actions file
'use server'
 
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
 
export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string
 
  const post = await db.post.create({
    data: { title, content, createdAt: new Date() },
  })
 
  revalidatePath('/blog')
  return { success: true, postId: post.id }
}

Progressive Enhancement with action Prop

Pass a Server Action directly to a form's action attribute. The form works without JavaScript loaded:

// app/blog/new/page.tsx — Server Component
import { createPost } from '@/app/actions'
 
export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" required />
      <textarea name="content" placeholder="Post content" required />
      <button type="submit">Publish</button>
    </form>
  )
}

When JavaScript loads, Next.js intercepts the submission and calls the action without a full page reload.

Validation with Zod

Always validate on the server — client-side validation can be bypassed:

// app/actions.ts
'use server'
 
import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
 
const PostSchema = z.object({
  title: z.string().min(5, 'Title must be at least 5 characters'),
  content: z.string().min(20, 'Content must be at least 20 characters'),
  slug: z.string().regex(/^[a-z0-9-]+$/, 'Slug can only contain lowercase letters, numbers, and hyphens'),
})
 
export async function createPost(formData: FormData) {
  const raw = {
    title: formData.get('title'),
    content: formData.get('content'),
    slug: formData.get('slug'),
  }
 
  const result = PostSchema.safeParse(raw)
 
  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors }
  }
 
  await db.post.create({ data: result.data })
  revalidatePath('/blog')
 
  return { success: true }
}

Handling Action State with useFormState

Display validation errors returned from Server Actions:

'use client'
 
import { useFormState, useFormStatus } from 'react-dom'
import { createPost } from '@/app/actions'
 
function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Publishing...' : 'Publish Post'}
    </button>
  )
}
 
const initialState = { errors: {}, success: false }
 
export function CreatePostForm() {
  const [state, formAction] = useFormState(createPost, initialState)
 
  return (
    <form action={formAction}>
      <div>
        <input name="title" placeholder="Title" />
        {state.errors?.title && <p className="text-red-500">{state.errors.title[0]}</p>}
      </div>
      <div>
        <textarea name="content" placeholder="Content" />
        {state.errors?.content && <p className="text-red-500">{state.errors.content[0]}</p>}
      </div>
      <SubmitButton />
      {state.success && <p className="text-green-500">Post published!</p>}
    </form>
  )
}

Authentication in Server Actions

Check authentication at the start of every action that modifies data:

// app/actions.ts
'use server'
 
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
 
export async function deletePost(postId: string) {
  const session = await auth()
 
  if (!session?.user) {
    return { error: 'You must be logged in' }
  }
 
  const post = await db.post.findUnique({ where: { id: postId } })
 
  if (!post) return { error: 'Post not found' }
 
  if (post.authorId !== session.user.id && session.user.role !== 'admin') {
    return { error: 'Not authorized to delete this post' }
  }
 
  await db.post.delete({ where: { id: postId } })
  revalidatePath('/blog')
 
  return { success: true }
}

Optimistic Updates with useOptimistic

Update the UI immediately while the server action is pending:

'use client'
 
import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/app/actions'
 
type Post = { id: string; title: string; likes: number; likedByUser: boolean }
 
export function PostCard({ post }: { post: Post }) {
  const [isPending, startTransition] = useTransition()
  const [optimisticPost, setOptimisticPost] = useOptimistic(
    post,
    (state, liked: boolean) => ({
      ...state,
      likedByUser: liked,
      likes: liked ? state.likes + 1 : state.likes - 1,
    })
  )
 
  function handleLike() {
    startTransition(async () => {
      setOptimisticPost(!optimisticPost.likedByUser)
      await toggleLike(post.id)
    })
  }
 
  return (
    <div>
      <h2>{post.title}</h2>
      <button onClick={handleLike} disabled={isPending}>
        {optimisticPost.likedByUser ? '❤️' : '🤍'} {optimisticPost.likes}
      </button>
    </div>
  )
}

Redirecting After Mutations

Use redirect() from next/navigation inside Server Actions:

// app/actions.ts
'use server'
 
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
 
export async function createAndRedirect(formData: FormData) {
  const title = formData.get('title') as string
 
  const post = await db.post.create({
    data: { title, content: '' },
  })
 
  revalidatePath('/blog')
  redirect(`/blog/${post.id}/edit`) // Throws internally — do not wrap in try/catch
}

Note: redirect() works by throwing a special error. Do not call it inside a try/catch block unless you re-throw it.

Common Mistakes

  • Not revalidating after mutations — the UI shows stale data because Next.js serves cached content
  • Wrapping redirect() in a try/catch, which catches the redirect and suppresses it
  • Forgetting to authenticate — Server Actions are callable from any client that knows the endpoint
  • Putting large database queries in actions that are called on every keystroke
  • Not using useFormStatus to disable the submit button during pending state, causing double-submits

Best Practices

  • Define shared Server Actions in a dedicated app/actions.ts file and import where needed
  • Validate all input with Zod before any database or side-effect operations
  • Always check authentication at the top of mutation actions
  • Call revalidatePath or revalidateTag after every mutation to keep the UI fresh
  • Use useFormState + useFormStatus for rich form feedback without extra state management
  • Use useOptimistic for instant UI feedback on toggle and like interactions

Key Takeaways

  • Server Actions are marked with 'use server' and run only on the server — the code never reaches the browser
  • Passing a Server Action to form action= enables progressive enhancement — forms work without JavaScript
  • useFormState captures the return value of a Server Action and updates the UI with errors or success state
  • useFormStatus provides a pending boolean to disable buttons during form submission
  • Always validate with Zod on the server — client-side validation can be bypassed
  • revalidatePath and revalidateTag must be called after mutations to invalidate Next.js caches
  • redirect() inside Server Actions throws internally — never wrap it in try/catch
  • Authentication must be checked inside every action — there is no automatic protection

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading