Cheat SheetsNext.jsMutations

Mutations — Cheat Sheet

Next.js · 5 topics. Download the PDF or the Instagram carousel and share it.

Cheat Sheet · AiCanCode.org
Mutations
Next.js5 topicsQuick revision reference
1

Server Actions

A function that runs on the server, called from the client as if it were local. It removes the API route from the middle of every write — and it is a public endpoint, whatever it looks like.

  • A server action compiles to a public POST endpoint — the form is not a gate, so authenticate and authorise inside the action
  • Never trust identity from a hidden input; read the user from the session inside the action
  • Arguments and return values must be serializable, and actions must be async
  • redirect() and notFound() throw, so they must be called outside a try/catch
  • Return validation errors rather than throwing, so the user keeps their input; never use an action for reads
use server, form action, or called directly
// A whole file of actions — the usual shape
// features/problems/actions.ts
'use server'

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

// Or inline in a server component
export default function Page() {
  async function del(formData: FormData) {
    'use server'
    await db.problem.delete({ where: { id: formData.get('id') } })
    revalidatePath('/problems')
  }
  return <form action={del}><input type="hidden" name="id" value={id} /></form>
}

// From a form — works with JavaScript disabled
<form action={createProblem}>
  <input name="title" required />
  <button>Create</button>
</form>

// From a client component
'use client'
import { createProblem } from '@/features/problems/actions'
<button onClick={() => startTransition(() => createProblem(fd))}>

// Actions must be async, and their arguments and return value must
// be serializable — the same rule as props crossing the boundary.
2

Forms with Actions

useActionState and useFormStatus give a form its errors and its pending state without any client state at all — and the form still submits with JavaScript disabled.

  • useActionState turns an action into a reducer over form state, returning errors and values without client state
  • Return the submitted values with the errors and use defaultValue, so a failed submit does not clear the form
  • useFormStatus reads the nearest parent form, which makes a shared submit button possible
  • A form posting to an action works before hydration and without JavaScript — real progressive enhancement
  • Share one schema for both sides: client validation is a convenience, the server check is the rule
state, formAction, isPending
// The action takes the previous state as its first argument
'use server'
export async function signup(prevState, formData: FormData) {
  const parsed = SignupSchema.safeParse(Object.fromEntries(formData))
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors, values: Object.fromEntries(formData) }
  }
  try {
    await createUser(parsed.data)
  } catch (e) {
    if (e.code === 'P2002') return { errors: { email: ['Already registered'] } }
    return { message: 'Something went wrong. Please try again.' }
  }
  redirect('/welcome')
}

// The client side
'use client'
import { useActionState } from 'react'      // React 19; useFormState in 18

export function SignupForm() {
  const [state, formAction, isPending] = useActionState(signup, {})

  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" defaultValue={state.values?.email}
             aria-invalid={!!state.errors?.email}
             aria-describedby="email-error" />
      {state.errors?.email && <p id="email-error" role="alert">{state.errors.email[0]}</p>}

      <button disabled={isPending}>{isPending ? 'Creating…' : 'Create account'}</button>
      {state.message && <p role="alert">{state.message}</p>}
    </form>
  )
}
// Note defaultValue, not value — the inputs stay uncontrolled, and
// the returned values repopulate them after a failed submit.
3

Optimistic Updates with useOptimistic

Show the result before the server confirms it, and let React roll it back automatically if the action fails. The rollback is the part that makes it safe.

  • useOptimistic shows a provisional value and discards it automatically when the action settles
  • The rollback is automatic on failure, which is what makes the pattern safe to use
  • Pair a rollback with a message and mark pending items, or a vanishing row looks like a bug
  • Use it for likes, toggles and reordering — never for payments or submissions
  • Optimistic state must be updated inside a transition, and it is a temporary view rather than a second source of truth
Optimistic state is discarded when the action settles
'use client'
import { useOptimistic, startTransition } from 'react'

export function Todos({ todos }) {          // todos come from the server
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (state, newTodo) => [...state, { ...newTodo, pending: true }],
  )

  async function action(formData: FormData) {
    const title = formData.get('title') as string
    startTransition(() => addOptimistic({ id: crypto.randomUUID(), title }))
    await createTodo(title)                 // the server action
  }

  return (
    <>
      <form action={action}><input name="title" /><button>Add</button></form>
      <ul>
        {optimisticTodos.map((t) => (
          <li key={t.id} className={t.pending ? 'opacity-50' : ''}>{t.title}</li>
        ))}
      </ul>
    </>
  )
}

// When the action finishes and the server data re-renders, the
// optimistic value is discarded and the real list takes over. If the
// action THREW, the optimistic value simply vanishes — the rollback
// is automatic, which is the whole appeal.
4

Route Handlers

route.ts gives you a real HTTP endpoint. Server actions replaced most of the reasons to write one, but webhooks, public APIs and file responses still need it.

  • route.ts exports functions named after HTTP methods; unexported methods return 405 automatically
  • Server components and server actions removed most reasons to write one — what remains is webhooks, public APIs and non-JSON responses
  • Webhooks need the raw body for signature verification, a fast response, and idempotent handling
  • GET handlers were cached by default in Next 14 and are not in Next 15 — set the behaviour explicitly
  • Every route handler is a public endpoint: authenticate inside it and rate-limit anything unauthenticated
GET, POST, params, and honest status codes
// app/api/problems/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(req: NextRequest) {
  const topic = req.nextUrl.searchParams.get('topic')
  const problems = await db.problem.findMany({ where: { topic } })
  return NextResponse.json({ items: problems })
}

export async function POST(req: NextRequest) {
  const body = await req.json()
  const parsed = CreateSchema.safeParse(body)
  if (!parsed.success) {
    return NextResponse.json(
      { error: { code: 'validation_failed', fields: parsed.error.flatten().fieldErrors } },
      { status: 422 },
    )
  }
  const created = await db.problem.create({ data: parsed.data })
  return NextResponse.json(created, { status: 201 })
}

// app/api/problems/[slug]/route.ts
export async function GET(req: NextRequest, { params }) {
  const { slug } = await params                 // a Promise in Next 15
  const problem = await getProblem(slug)
  if (!problem) return new NextResponse('Not found', { status: 404 })
  return NextResponse.json(problem)
}

// Any method not exported returns 405 automatically.
// A folder cannot have both page.tsx and route.ts — one URL, one
// handler.
5

Middleware

Code that runs before every matched request, on the edge, with no database and a tight time budget. Right for redirects and coarse gates; wrong as your authorisation layer.

  • Middleware runs before matched requests on the edge — the matcher is what keeps it off static assets
  • The edge runtime has no Node built-ins and no database access, so session lookups cannot happen here
  • It runs on prefetches too, so it must stay within a few milliseconds
  • A 2025 CVE allowed middleware to be bypassed with a crafted header — never rely on it as the only gate
  • Authorise where data is read or written; a Data Access Layer makes that hard to forget
One file, and a matcher that excludes assets
// middleware.ts — at the project root, beside app/
import { NextRequest, NextResponse } from 'next/server'

export function middleware(req: NextRequest) {
  const session = req.cookies.get('sid')

  if (!session && req.nextUrl.pathname.startsWith('/dashboard')) {
    const url = new URL('/login', req.url)
    url.searchParams.set('from', req.nextUrl.pathname)     // come back after
    return NextResponse.redirect(url)
  }

  const res = NextResponse.next()
  res.headers.set('x-request-id', crypto.randomUUID())
  return res
}

// The matcher is the performance control. Without it, middleware runs
// for every request including static assets.
export const config = {
  matcher: [
    '/dashboard/:path*',
    '/admin/:path*',
    // or everything except assets and images:
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}

// It also runs on prefetches, so a redirect here fires more often
// than you expect — keep it cheap.
Learn this free with Aria, your AI tutor → AiCanCode.org/learn/nextjs