Dev Logs
/Next.js/ Chapter 12: Server Actions & Mutations
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
    • Plain English Explanation
    • Declaring an action
    • Inline in a Server Component
    • In a separate file (preferred)
    • Forms with useActionState
    • useFormStatus for nested components
    • useOptimistic — update before the server replies
    • Actions outside forms
    • Bound arguments
    • From an event handler
    • formAction on a button — two actions, one form
    • Refreshing data after a mutation
    • Redirecting after an action
    • Security — the part people get wrong
    • Always authenticate and authorize inside the action
    • Always validate the input
    • Don't put secrets in return values
    • Rate limiting
    • File uploads
    • Common Pitfalls
    • . Forgetting 'use server'
    • . Throwing instead of returning for validation
    • . Trusting IDs from the client
    • . No auth check because the button is hidden
    • . Forgetting to revalidate
    • . revalidateTag with one argument
    • . redirect() inside a try
    • . useFormStatus outside the form
    • . Defining an action inside a Client Component
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Security audit
    • Problem 2: Fix the form
    • Problem 3: Choose the revalidation
    • Problem 4: Build it
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

✍️ Chapter 12: Server Actions & Mutations

Writing data without writing an API, forms that work before JavaScript loads, and UI that updates before the server replies.

📖 Plain English Explanation

To save a form in a typical React app you: build an API endpoint, write a fetch call, serialize the body, handle the response, manage loading state, manage error state, and then figure out how to refresh whatever data just changed.

A Server Action collapses all of that into a function.

tsx
async function createPost(formData: FormData) {
  'use server'
  await db.post.create({ data: { title: formData.get('title') as string } })
}

export default function Page() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button>Create</button>
    </form>
  )
}

That's a complete, working, database-writing form. No API route. No fetch. No onSubmit.

Behind the scenes Next.js still creates an endpoint and still POSTs to it — it just generates all of that for you and gives you a function call instead of a network protocol.

There's a bonus that's easy to overlook: because it's a real <form action>, it works with JavaScript disabled. The browser does a native form POST, the server runs the action, and the page re-renders. Once JavaScript loads, the same form upgrades to a client-side submission with no page reload. Progressive enhancement, for free.

🏷️ Declaring an action

Two forms.

Inline in a Server Component

tsx
// app/posts/page.tsx
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export default function Page() {
  async function createPost(formData: FormData) {
    'use server'
    const title = formData.get('title') as string
    await db.post.create({ data: { title } })
    revalidatePath('/posts')
  }

  return (
    <form action={createPost}>
      <input name="title" required />
      <button>Create</button>
    </form>
  )
}

In a separate file (preferred)

ts
// app/actions.ts
'use server'                    // ← applies to every export in the file

import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  await db.post.create({ data: { title } })
  revalidatePath('/posts')
}

export async function deletePost(id: string) {
  await db.post.delete({ where: { id } })
  revalidatePath('/posts')
}
js
// app/actions.js
'use server'

import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export async function createPost(formData) {
  const title = formData.get('title')
  await db.post.create({ data: { title } })
  revalidatePath('/posts')
}

export async function deletePost(id) {
  await db.post.delete({ where: { id } })
  revalidatePath('/posts')
}

A separate file is better because Client Components can import actions from it. They can't define them.

Every Server Action must be async, even if it doesn't await anything.

📋 Forms with useActionState

The inline version has no way to show errors or a pending state. useActionState fixes that.

ts
// app/actions.ts
'use server'

import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

const schema = z.object({
  title: z.string().min(3, 'Title must be at least 3 characters'),
  body: z.string().min(10, 'Body is too short'),
})

export type FormState = {
  errors?: { title?: string[]; body?: string[] }
  message?: string
  success?: boolean
}

export async function createPost(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const parsed = schema.safeParse({
    title: formData.get('title'),
    body: formData.get('body'),
  })

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

  try {
    await db.post.create({ data: parsed.data })
  } catch (error) {
    console.error(error)
    return { message: 'Could not save the post. Please try again.' }
  }

  revalidatePath('/posts')
  return { success: true, message: 'Post created.' }
}
js
// app/actions.js
'use server'

import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

const schema = z.object({
  title: z.string().min(3, 'Title must be at least 3 characters'),
  body: z.string().min(10, 'Body is too short'),
})

export async function createPost(prevState, formData) {
  const parsed = schema.safeParse({
    title: formData.get('title'),
    body: formData.get('body'),
  })

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

  try {
    await db.post.create({ data: parsed.data })
  } catch (error) {
    console.error(error)
    return { message: 'Could not save the post. Please try again.' }
  }

  revalidatePath('/posts')
  return { success: true, message: 'Post created.' }
}
tsx
// app/posts/PostForm.tsx
'use client'
import { useActionState } from 'react'
import { createPost, type FormState } from '@/app/actions'

const initialState: FormState = {}

export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, initialState)

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <input name="title" placeholder="Title" aria-describedby="title-error" />
        {state.errors?.title && (
          <p id="title-error" className="text-sm text-red-600">
            {state.errors.title[0]}
          </p>
        )}
      </div>

      <div>
        <textarea name="body" placeholder="Write something…" aria-describedby="body-error" />
        {state.errors?.body && (
          <p id="body-error" className="text-sm text-red-600">
            {state.errors.body[0]}
          </p>
        )}
      </div>

      <button disabled={isPending}>{isPending ? 'Saving…' : 'Publish'}</button>

      {state.message && (
        <p className={state.success ? 'text-green-700' : 'text-red-600'}>
          {state.message}
        </p>
      )}
    </form>
  )
}
jsx
// app/posts/PostForm.js
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions'

export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, {})

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <input name="title" placeholder="Title" />
        {state.errors?.title && (
          <p className="text-sm text-red-600">{state.errors.title[0]}</p>
        )}
      </div>

      <div>
        <textarea name="body" placeholder="Write something…" />
        {state.errors?.body && (
          <p className="text-sm text-red-600">{state.errors.body[0]}</p>
        )}
      </div>

      <button disabled={isPending}>{isPending ? 'Saving…' : 'Publish'}</button>

      {state.message && <p>{state.message}</p>}
    </form>
  )
}

Note the signature change: with useActionState, the action's first parameter is the previous state and formData is second.

Return errors, don't throw them. A thrown error bubbles to error.tsx, which unmounts the form and destroys everything the user typed.

⏳ useFormStatus for nested components

useActionState gives you isPending in the component that owns the form. If your submit button is a separate reusable component, use useFormStatus:

tsx
// app/components/SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom'

export function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus()
  return (
    <button disabled={pending}>
      {pending ? 'Working…' : children}
    </button>
  )
}
jsx
// app/components/SubmitButton.js
'use client'
import { useFormStatus } from 'react-dom'

export function SubmitButton({ children }) {
  const { pending } = useFormStatus()
  return <button disabled={pending}>{pending ? 'Working…' : children}</button>
}

It must be rendered inside the <form> — it reads status from the nearest form context, not from a prop.

⚡ useOptimistic — update before the server replies

For actions that almost always succeed, waiting 300ms for a round-trip feels sluggish. useOptimistic shows the result immediately and reconciles when the real response arrives.

tsx
// app/todos/TodoList.tsx
'use client'
import { useOptimistic, useRef } from 'react'
import { addTodo } from '@/app/actions'

type Todo = { id: string; text: string; pending?: boolean }

export function TodoList({ todos }: { todos: Todo[] }) {
  const formRef = useRef<HTMLFormElement>(null)

  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (state: Todo[], newText: string) => [
      ...state,
      { id: crypto.randomUUID(), text: newText, pending: true },
    ]
  )

  return (
    <>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id} className={todo.pending ? 'opacity-50' : ''}>
            {todo.text}
          </li>
        ))}
      </ul>

      <form
        ref={formRef}
        action={async (formData) => {
          const text = formData.get('text') as string
          addOptimistic(text)       // appears instantly
          formRef.current?.reset()
          await addTodo(formData)   // then the server catches up
        }}
      >
        <input name="text" required />
        <button>Add</button>
      </form>
    </>
  )
}
jsx
// app/todos/TodoList.js
'use client'
import { useOptimistic, useRef } from 'react'
import { addTodo } from '@/app/actions'

export function TodoList({ todos }) {
  const formRef = useRef(null)

  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (state, newText) => [
      ...state,
      { id: crypto.randomUUID(), text: newText, pending: true },
    ]
  )

  return (
    <>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id} className={todo.pending ? 'opacity-50' : ''}>
            {todo.text}
          </li>
        ))}
      </ul>

      <form
        ref={formRef}
        action={async (formData) => {
          const text = formData.get('text')
          addOptimistic(text)
          formRef.current?.reset()
          await addTodo(formData)
        }}
      >
        <input name="text" required />
        <button>Add</button>
      </form>
    </>
  )
}

If the action fails, React automatically discards the optimistic state and re-renders with the real data. You don't write rollback logic.

Use it for likely-to-succeed, low-stakes operations: likes, todos, toggles, reorderings. Don't use it for payments.

🔘 Actions outside forms

Bound arguments

bind pre-fills arguments — and unlike a hidden input, the value can't be tampered with by the client:

tsx
// app/posts/PostRow.tsx
import { deletePost } from '@/app/actions'

export function PostRow({ post }: { post: Post }) {
  const deleteThisPost = deletePost.bind(null, post.id)

  return (
    <li>
      {post.title}
      <form action={deleteThisPost}>
        <button>Delete</button>
      </form>
    </li>
  )
}
jsx
// app/posts/PostRow.js
import { deletePost } from '@/app/actions'

export function PostRow({ post }) {
  const deleteThisPost = deletePost.bind(null, post.id)

  return (
    <li>
      {post.title}
      <form action={deleteThisPost}>
        <button>Delete</button>
      </form>
    </li>
  )
}

Note this works from a Server Component — the action reference serializes across the boundary.

From an event handler

tsx
// app/components/LikeButton.tsx
'use client'
import { useTransition } from 'react'
import { toggleLike } from '@/app/actions'

export function LikeButton({ postId, liked }: { postId: string; liked: boolean }) {
  const [isPending, startTransition] = useTransition()

  return (
    <button
      disabled={isPending}
      onClick={() => startTransition(() => toggleLike(postId))}
    >
      {liked ? '♥' : '♡'}
    </button>
  )
}
jsx
// app/components/LikeButton.js
'use client'
import { useTransition } from 'react'
import { toggleLike } from '@/app/actions'

export function LikeButton({ postId, liked }) {
  const [isPending, startTransition] = useTransition()

  return (
    <button
      disabled={isPending}
      onClick={() => startTransition(() => toggleLike(postId))}
    >
      {liked ? '♥' : '♡'}
    </button>
  )
}

Wrapping in startTransition keeps the UI responsive and gives you a pending flag.

formAction on a button — two actions, one form

tsx
<form action={publishPost}>
  <input name="title" />
  <button>Publish</button>
  <button formAction={saveDraft}>Save draft</button>
</form>

🔄 Refreshing data after a mutation

Writing to the database doesn't update what's on screen — the server-rendered data is now stale. You have four tools.

ts
// app/actions.ts
'use server'

import { revalidatePath, revalidateTag, updateTag, refresh } from 'next/cache'
FunctionWhat it doesUse when
revalidatePath(path)Invalidates a specific route's cacheYou know exactly which page changed
revalidateTag(tag, profile)Marks tagged data stale; readers see stale content while it refreshesBlog posts, catalogs — a short delay is fine
updateTag(tag)Expires and immediately refreshes within the same requestThe user must see their own change right away
refresh()Re-renders the current route on the clientRefresh visible UI after an action

⚠️ Changed in Next.js 16

revalidateTag now requires a second argument — a cacheLife profile. The one-argument form is deprecated and produces a TypeScript error.

ts
// ❌ Next.js 15
revalidateTag('posts')

// ✅ Next.js 16
revalidateTag('posts', 'max')

updateTag() and refresh() are new in Next.js 16. updateTag gives read-your-writes semantics — the user sees their change immediately rather than stale data while a background revalidation runs.

The distinction that matters in practice:

ts
// app/actions.ts
'use server'
import { revalidateTag, updateTag } from 'next/cache'
import { db } from '@/lib/db'

// A blog post edit — other readers can see stale content for a moment
export async function publishArticle(id: string) {
  await db.article.update({ where: { id }, data: { published: true } })
  revalidateTag(`article-${id}`, 'max')
}

// A user editing their own profile — they must see it change instantly
export async function updateProfile(userId: string, data: ProfileInput) {
  await db.user.update({ where: { id: userId }, data })
  updateTag(`user-${userId}`)          // read-your-writes
}
js
// app/actions.js
'use server'
import { revalidateTag, updateTag } from 'next/cache'
import { db } from '@/lib/db'

export async function publishArticle(id) {
  await db.article.update({ where: { id }, data: { published: true } })
  revalidateTag(`article-${id}`, 'max')
}

export async function updateProfile(userId, data) {
  await db.user.update({ where: { id: userId }, data })
  updateTag(`user-${userId}`)
}

And refresh() when you just need the current screen redrawn:

ts
'use server'
import { refresh } from 'next/cache'

export async function markAsRead(notificationId: string) {
  await db.notification.update({
    where: { id: notificationId },
    data: { read: true },
  })
  refresh()      // the unread badge in the header updates
}

Chapter 15 covers all of this in depth.

🔀 Redirecting after an action

ts
// app/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const post = await db.post.create({
    data: { title: formData.get('title') as string },
  })

  revalidatePath('/posts')
  redirect(`/posts/${post.id}`)        // must be last — it throws
}

Remember from Chapter 9: redirect() throws, so it must be outside any try/catch and nothing after it runs.

🔐 Security — the part people get wrong

A Server Action is a public HTTP endpoint. Next.js generates a URL for it and anyone can POST to it directly with curl. The fact that your UI only shows the button to admins means nothing.

Always authenticate and authorize inside the action

ts
// app/actions.ts
'use server'

import { verifySession } from '@/lib/dal'
import { forbidden } from 'next/navigation'
import { db } from '@/lib/db'

export async function deletePost(postId: string) {
  // 1. Authenticate
  const session = await verifySession()
  if (!session) return { error: 'Not signed in' }

  // 2. Authorize — does THIS user own THIS post?
  const post = await db.post.findUnique({
    where: { id: postId },
    select: { authorId: true },
  })
  if (!post) return { error: 'Not found' }
  if (post.authorId !== session.userId && session.role !== 'admin') {
    return { error: 'Not allowed' }
  }

  // 3. Only now, mutate
  await db.post.delete({ where: { id: postId } })
  revalidatePath('/posts')
  return { success: true }
}
js
// app/actions.js
'use server'

import { verifySession } from '@/lib/dal'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export async function deletePost(postId) {
  const session = await verifySession()
  if (!session) return { error: 'Not signed in' }

  const post = await db.post.findUnique({
    where: { id: postId },
    select: { authorId: true },
  })
  if (!post) return { error: 'Not found' }
  if (post.authorId !== session.userId && session.role !== 'admin') {
    return { error: 'Not allowed' }
  }

  await db.post.delete({ where: { id: postId } })
  revalidatePath('/posts')
  return { success: true }
}

Always validate the input

FormData comes from the client. Nothing stops someone sending arbitrary fields:

ts
// ❌ mass assignment — a crafted request could set role: 'admin'
export async function updateUser(formData: FormData) {
  const data = Object.fromEntries(formData)
  await db.user.update({ where: { id: session.userId }, data })
}

// ✅ explicit allow-list via schema
const schema = z.object({
  name: z.string().min(1).max(100),
  bio: z.string().max(500).optional(),
})

export async function updateUser(formData: FormData) {
  const session = await verifySession()
  if (!session) return { error: 'Not signed in' }

  const parsed = schema.safeParse(Object.fromEntries(formData))
  if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }

  await db.user.update({ where: { id: session.userId }, data: parsed.data })
}

Note the where: { id: session.userId } — the target comes from the session, not from user input. Never let the client tell you whose record to modify.

Don't put secrets in return values

Whatever an action returns is serialized to the browser. Return the minimum.

Rate limiting

Actions are endpoints; treat them like endpoints:

ts
'use server'
import { headers } from 'next/headers'
import { rateLimit } from '@/lib/rate-limit'

export async function submitContactForm(prev: State, formData: FormData) {
  const ip = (await headers()).get('x-forwarded-for') ?? 'unknown'
  const { success } = await rateLimit(ip)
  if (!success) return { message: 'Too many requests. Try again shortly.' }
  // ...
}

📤 File uploads

FormData handles files natively:

tsx
// app/upload/page.tsx
import { uploadAvatar } from '@/app/actions'

export default function Page() {
  return (
    <form action={uploadAvatar}>
      <input type="file" name="avatar" accept="image/*" required />
      <button>Upload</button>
    </form>
  )
}
ts
// app/actions.ts
'use server'
import { put } from '@vercel/blob'
import { verifySession } from '@/lib/dal'

const MAX_BYTES = 5 * 1024 * 1024
const ALLOWED = ['image/jpeg', 'image/png', 'image/webp']

export async function uploadAvatar(formData: FormData) {
  const session = await verifySession()
  if (!session) return { error: 'Not signed in' }

  const file = formData.get('avatar') as File
  if (!file || file.size === 0) return { error: 'No file selected' }
  if (file.size > MAX_BYTES) return { error: 'File must be under 5MB' }
  if (!ALLOWED.includes(file.type)) return { error: 'Only JPEG, PNG, or WebP' }

  const blob = await put(`avatars/${session.userId}`, file, { access: 'public' })
  await db.user.update({
    where: { id: session.userId },
    data: { avatarUrl: blob.url },
  })

  revalidatePath('/settings')
  return { success: true }
}

Validate size and type on the server. The accept attribute is a UI hint, not a control.

Note that file.type comes from the client too. For anything sensitive, check magic bytes rather than trusting the declared MIME type.

⚠️ Common Pitfalls

1. Forgetting 'use server'

Error: Functions cannot be passed directly to Client Components

The directive must be the first statement in the file (or the first statement in the function, for inline actions).

2. Throwing instead of returning for validation

tsx
// ❌ error.tsx replaces the form, user's input is gone
if (!parsed.success) throw new Error('Invalid')

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

3. Trusting IDs from the client

ts
// ❌ anyone can POST any userId
export async function updateProfile(formData: FormData) {
  const userId = formData.get('userId') as string
  await db.user.update({ where: { id: userId }, data: {...} })
}

// ✅ target comes from the session
const session = await verifySession()
await db.user.update({ where: { id: session.userId }, data: {...} })

4. No auth check because the button is hidden

The button being hidden in the UI does not hide the endpoint. Check in the action, every time.

5. Forgetting to revalidate

ts
// ❌ database updated, screen still shows old data
await db.post.create({ data })

// ✅
await db.post.create({ data })
revalidatePath('/posts')

6. revalidateTag with one argument

ts
// ❌ TypeScript error in Next.js 16
revalidateTag('posts')

// ✅
revalidateTag('posts', 'max')

7. redirect() inside a try

Same trap as Chapter 9. Put it after the try, or use unstable_rethrow.

8. useFormStatus outside the form

tsx
// ❌ pending is always false
<SubmitButton />
<form action={action}>…</form>

// ✅
<form action={action}>
  <SubmitButton />
</form>

9. Defining an action inside a Client Component

tsx
// ❌
'use client'
export function Form() {
  async function save() { 'use server'; /* ... */ }   // not allowed
}

Actions must be defined in Server Components or 'use server' modules. Client Components import them.

🎯 When & Why to Use

Server Action when:
  ✅ A form in your own app writes data
  ✅ Buttons that mutate (delete, like, toggle, reorder)
  ✅ You want progressive enhancement without effort
  ✅ You'd otherwise write an API route only your own UI calls

Route Handler instead when:
  ✅ An external service calls you (webhooks)
  ✅ A mobile app or third party needs the endpoint
  ✅ You need a non-POST method, or custom headers/status
  ✅ You're streaming a response

Client-side fetch instead when:
  ✅ Optimistic UI with complex client cache management
  ✅ You're already committed to TanStack Query / Redux Toolkit Query

🏋️ Mini Practice Problems

Problem 1: Security audit

Find every vulnerability:

ts
'use server'
export async function updatePost(formData: FormData) {
  const id = formData.get('id') as string
  const data = Object.fromEntries(formData)
  await db.post.update({ where: { id }, data })
  return { post: await db.post.findUnique({ where: { id } }) }
}

There are at least four. Rewrite it.

Problem 2: Fix the form

tsx
'use client'
import { createUser } from './actions'

export function Form() {
  const [state, formAction] = useActionState(createUser)
  return (
    <form action={formAction}>
      <input name="email" />
      <SubmitButton />
    </form>
  )
}
ts
'use server'
export async function createUser(formData: FormData) {
  const email = formData.get('email') as string
  if (!email.includes('@')) throw new Error('Bad email')
  await db.user.create({ data: { email } })
}

Three bugs. Name and fix them.

Problem 3: Choose the revalidation

Which of revalidatePath, revalidateTag, updateTag, or refresh for each?

  • A. A user updates their display name and must see it in the header immediately
  • B. An editor publishes an article; readers can see the old version for a few seconds
  • C. Deleting a row from a table on the current page
  • D. A price change that affects /products and /products/[id]

Problem 4: Build it

A comment form that:

  • Validates: non-empty, max 500 chars
  • Shows field errors without losing the typed text
  • Disables the button while submitting
  • Optimistically appends the comment, greyed out
  • Requires login, checked in the action
  • Rate-limits to 5 comments per minute per user

💼 Interview Notes

Common Questions

Q: What is a Server Action? An async function marked 'use server' that runs on the server and can be called directly from a component — including as a <form action>. Next.js generates the endpoint and the client-side call for you.

Q: How do Server Actions give you progressive enhancement? <form action={serverAction}> renders as a real HTML form. Without JavaScript, the browser performs a native POST and the server handles it. With JavaScript, React intercepts and submits without a page reload. Same code, both paths.

Q: Why return errors instead of throwing them? A thrown error propagates to the nearest error.tsx, which unmounts the form and discards the user's input. Returning a serializable error object lets you render field-level messages while keeping the form intact.

Q: Are Server Actions secure by default? No. Each one is a public HTTP endpoint. You must authenticate, authorize, and validate input inside the action itself — UI-level gating means nothing to someone using curl.

Q: What's the difference between revalidateTag and updateTag? revalidateTag marks data stale; the next readers get stale content while a fresh copy loads in the background. updateTag expires and refreshes in the same request, so the user who made the change sees it immediately — read-your-writes.

Q: How does useOptimistic work? You give it the real state and a reducer. Calling the dispatch function applies an optimistic update immediately. When the action settles, React discards the optimistic state and re-renders from the real data — including automatically on failure, so there's no rollback code to write.

Q: When would you use a Route Handler instead of a Server Action? Webhooks, third-party or mobile clients, non-POST methods, custom status codes and headers, and streaming responses.

🏢 Asked at Companies

  • Vercel: "Explain how a form submits before JavaScript has loaded, and what changes once it has."
  • Stripe: "Here's a delete action. Attack it. Then fix it."
  • Shopify: "Build an add-to-cart button with optimistic UI. What happens if the item is out of stock?"
  • Linear: "Compare Server Actions to a REST endpoint plus TanStack Query. When is each the right call?"

📊 Visual Memory Aid

              THE ROUND TRIP

  <form action={createPost}>
         │
         │ 1. React serializes the FormData and POSTs
         ▼
  ┌──────────────────────────┐
  │  SERVER                  │
  │  'use server'            │
  │   ├─ verify session ✅   │
  │   ├─ validate input ✅   │
  │   ├─ authorize ✅        │
  │   ├─ mutate DB           │
  │   └─ revalidate          │
  └───────────┬──────────────┘
              │ 2. returns state + fresh RSC payload
              ▼
        UI re-renders with new data
        (no manual refetch)


              WHICH REVALIDATION

  revalidatePath('/posts')      one route
  revalidateTag('x', 'max')     tagged data, stale-while-revalidate
  updateTag('x')                tagged data, immediate (read-your-writes)
  refresh()                     re-render current route on the client


              SECURITY CHECKLIST

  ┌─ every Server Action ────────────────────┐
  │ □ authenticated? (session exists)        │
  │ □ authorized?    (this user, this record)│
  │ □ input validated? (schema, not spread)  │
  │ □ target from session, not from formData │
  │ □ rate limited?  (public endpoint)       │
  │ □ return value free of secrets?          │
  └──────────────────────────────────────────┘

🎯 Key Takeaways

  1. 'use server' turns a function into a callable endpoint. <form action={fn}> gives you a working, progressively-enhanced form with no API route and no fetch.
  2. Return errors, never throw them for validation. Throwing hands control to error.tsx and wipes the form.
  3. Every Server Action is a public endpoint. Authenticate, authorize, and validate inside it — and derive the target record from the session, not from client input.
  4. Mutating isn't enough; you must revalidate. updateTag() for read-your-writes, revalidateTag(tag, profile) for stale-while-revalidate, revalidatePath() for a known route, refresh() for the current screen.
  5. useActionState for errors and pending, useFormStatus for nested buttons, useOptimistic for instant feedback — three hooks that cover essentially every form UX requirement.

Next Chapter: Route Handlers →

Practice: Build a full CRUD todo list with Server Actions — validation with returned errors, optimistic add and delete, a session check in every action, and correct revalidation. Then disable JavaScript in DevTools and confirm the forms still work.


PreviousChapter 11: Data FetchingNextChapter 13: Route Handlers

Open source, free forever. Built by iammhador.

Contribute on GitHub