Skip to solution
mediumFrontend

Server Actions — how do mutations work without API routes in Next.js 15?

190 views
01

Understand the problem

Question presented to candidate: "Walk me through a form that creates a record in the App Router. Where does the mutation go, and how does the list update afterwards?"

What a strong answer should cover:

  • The mutation is a function marked "use server", passed directly to <form action>. No route file, no fetch, no client state for the request.
  • The step people forget: the cache. After mutating you call the framework's revalidation — by path or by tag — so the affected Server Components re-render on the next request.
  • Without that, the write succeeds and the UI still shows stale data, which is the classic "it worked but nothing changed" bug.
  • Because the server re-renders, no client refetch is needed — the updated markup streams back and replaces the affected segment.
  • Errors: catch inside the action and return them as state via useActionState; throwing reaches an error boundary, which is disproportionate for validation.
  • Redirect after a successful mutation is a server-side call, not a router push.
  • For instant feedback, useOptimistic shows the change before the round trip completes.
  • Security is not optional: the action is a public endpoint, so authentication, authorisation and validation live inside it.

Clarifying questions expected:

  • "Does the list that needs updating live in a Server Component?" — that decides revalidation versus client state.
  • "Should this feel instant, or is a spinner acceptable?" — that decides whether useOptimistic is worth it.

Code / implementation expected: Yes — this is a workflow question and a short end-to-end sketch is the natural answer.

server-actionsnextjs
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for Next.js interviews — assumes what the directive does. Difficulty: Medium

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Int

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

The full mutation flow, with the revalidation step made visible
Run Playground
import { useActionState, useState } from "react";
import { useFormStatus } from "react-dom";

// No Next.js server here, so the cache and revalidation are simulated in
// memory — but the shape is exactly the real one, and the point is what
// happens when you FORGET step two.

const db = { todos: [{ id: 1, title: "existing todo" }] };
let cache = { todos: null, stale: true };

// Stands in for a Server Component reading cached data.
function readTodosFromCache() {
  if (cache.stale) {
    cache.todos = db.todos.map((t) => ({ ...t }));   // "server re-render"
    cache.stale = false;
  }
  return cache.todos;
}
const revalidatePath = () => { cache.stale = true; };

function Submit() {
  // Must be INSIDE the form — in the component that renders the form it
  // would read false forever.
  const { pending } = useFormStatus();
  return <button type="submit" disabled={pending}>{pending ? "Adding…" : "Add"}</button>;
}

export default function App() {
  const [revalidate, setRevalidate] = useState(true);
  const [, forceRead] = useState(0);

  const [state, formAction] = useActionState(
    // Reducer signature: previous state first, FormData second.
    async (previous, formData) => {
      const title = String(formData.get("title") || "").trim();
      await new Promise((r) => setTimeout(r, 500));

      // Validation failures are RETURNED with the input, not thrown.
      if (!title) return { error: "Title is required", title, n: previous.n };

      db.todos.push({ id: Date.now(), title });          // 1. WRITE

      if (revalidate) revalidatePath("/todos");          // 2. INVALIDATE

      forceRead((n) => n + 1);
      return { error: null, title: "", n: previous.n + 1 };
    },
    { error: null, title: "", n: 0 },
  );

  const todos = readTodosFromCache();

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 500 }}>
      <label style={{ fontSize: 13, display: "block", marginBottom: 10 }}>
        <input type="checkbox" checked={revalidate} onChange={(e) => setRevalidate(e.target.checked)} />
        {" "}call <code>revalidatePath</code> after the write
      </label>

      <form action={formAction} style={{ border: "1px solid #ddd", borderRadius: 8, padding: 12 }}>
        <input
          name="title"
          defaultValue={state.title}
          placeholder="a new todo — try submitting it empty too"
          style={{ width: "100%", marginBottom: 6 }}
        />
        {state.error && <div role="alert" style={{ color: "#a33", fontSize: 13 }}>⚠ {state.error}</div>}
        <Submit />
      </form>

      <h4 style={{ margin: "14px 0 4px", fontSize: 14 }}>
        The list (a "Server Component" reading cache)
      </h4>
      <ul style={{ fontSize: 13, margin: 0 }}>
        {todos.map((t) => <li key={t.id}>{t.title}</li>)}
      </ul>
      <p style={{ fontSize: 12, color: "#666" }}>
        rows in the database: <strong>{db.todos.length}</strong> · rows the page
        is showing: <strong>{todos.length}</strong> · successful writes:{" "}
        <strong>{state.n}</strong>
      </p>

      <p style={{ fontSize: 13, color: "#666" }}>
        Untick the box and add a todo. The write succeeds — the database count
        goes up — and the list does not change, because nothing told the cache
        it was stale. That is the entire "it worked but nothing happened" bug,
        and it is the half of the answer candidates usually leave out.
      </p>
    </div>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 66 of 119 decoded in the React.js track. One more won't hurt.

Back to track