Skip to main content
Next.js · lesson 8 of 11
Next.js intermediate Lesson 8 of 11

Also part of: React

Server Actions

A Server Action wired to a form is a real POST endpoint: curl submits it, server validation rejects bad input, and updateTag reflects the change at once.

By SkillByExample · Updated

nextjs server-actions forms mutations

A Server Action is a plain async function with "use server" as the first line of its file, passed straight to a <form>’s action prop. The project setup reuses the Docker pattern from the first lesson — the node:26.13 image, the nextjs-modules volume, MSYS_NO_PATHCONV=1 on Git Bash — see it for why each flag is there.

”use server” Marks Every Export as an Action

Seven files set up the project: package.json, next.config.ts, tsconfig.json, and app/layout.tsx are unchanged from the first lesson; app/store.ts, app/actions.ts, and app/page.tsx are new.

{
  "name": "next-app",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.4.3",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "20.19.43",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0"
  }
}
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};

export default nextConfig;
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
  "exclude": ["node_modules"]
}
// app/layout.tsx — required; every page in the app renders inside this
export const metadata = {
  title: "Next App",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

app/store.ts stands in for a database — a plain in-memory array, nothing more:

// app/store.ts — stands in for a database
const notes: string[] = [];

export function getNotesRaw() {
  return notes;
}

export function pushNote(text: string) {
  notes.push(text);
}

app/actions.ts’s first line, "use server", is what turns every function this file exports into a Server Action — callable directly from a form, from another Server Component, or (as the rest of this lesson proves) from a plain HTTP client that knows nothing about React:

// app/actions.ts
"use server";

import { updateTag } from "next/cache";
import { pushNote } from "./store";

export async function addNote(formData: FormData) {
  const text = formData.get("text");
  if (typeof text !== "string" || text.trim().length === 0) {
    throw new Error("text is required");
  }
  pushNote(text.trim());
  updateTag("notes");
}

app/page.tsx passes addNote straight to a <form>’s action prop — no onSubmit, no client-side JavaScript of its own — and reads the current notes through a "use cache" function tagged "notes", the same cacheTag cacheLife and cacheTag used to invalidate a value on demand:

// app/page.tsx
import { cacheTag } from "next/cache";
import { addNote } from "./actions";
import { getNotesRaw } from "./store";

async function getNotes() {
  "use cache";
  cacheTag("notes");
  return getNotesRaw();
}

export default async function HomePage() {
  const notes = await getNotes();
  return (
    <div>
      <form action={addNote}>
        <input name="text" />
        <button type="submit">Add</button>
      </form>
      <ul>
        {notes.map((n, i) => (
          <li key={i}>{n}</li>
        ))}
      </ul>
    </div>
  );
}

Route Handlers showed a route that reads the request earning ƒ instead of ○. This page mutates data through a form — predict whether that makes it dynamic too, before building:

$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund

added 28 packages in 26s
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 64ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 5.1s
  Running TypeScript ...
  Finished TypeScript in 2.8s ...
  Finalizing page optimization ...

Route (app)      Revalidate  Expire
┌ ○ /                   15m      1y
└ ○ /_not-found


○  (Static)  prerendered as static content

/ still builds ○, with the same 15m/1y default Revalidate/Expire columns Caching with “use cache” first showed — nothing about rendering this page reads a request API, so Cache Components has no reason to treat it as dynamic. The Server Action it calls is a separate function reference embedded in the page’s own payload, not something that forces the page itself off the static path.

curl Can Submit the Form Directly

Start the app and ask for the rendered form. The hidden input Next.js adds names which function to call:

$ docker run -d --name nextjs-actions-1 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
86ee8d53d6cee3f1cc5c24d84c286edfbd66c4fd38335c66531ffef85febdb70
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ | grep -oE '<form[^>]*>|<input type="hidden" name="[^"]*"/>'
<form action="" encType="multipart/form-data" method="POST">
<input type="hidden" name="$ACTION_ID_408abcac44a30fa2d4d37860df22059e0cc91656ec"/>
$ grep -l "next-action" .next/static/chunks/*.js
.next/static/chunks/1j_b1xq_woyiy.js

action="" posts to the page’s own URL; method="POST" and encType="multipart/form-data" are what a plain HTML form (no JavaScript) sends on submit. The hidden $ACTION_ID_<hash> field is how Next.js, on the receiving end, knows which exported function in app/actions.ts to run — the hash encodes that function’s own identity, not anything a reader chooses. A browser with JavaScript enabled never relies on this at all; it instead sends a fetch carrying a next-action header, and the string above confirms that header name is really compiled into this page’s own client JavaScript, not a guess at what the Network tab would show. Both paths end up calling the exact same addNote function.

Grep the page for that hidden field’s value, then POST the same shape a real browser’s native form submission would — multipart fields, the $ACTION_ID field present, and an Origin header matching the server (a plain fetch/form submission always sends one; Next.js checks it against the request’s own host before running a Server Action). Capture the list both before and after, so updateTag’s effect is a checked assertion, not just narration:

$ before=$(curl -s http://localhost:3100/ | grep -oE '<ul>.*</ul>'); echo "$before"
<ul></ul>
$ aid=$(curl -s http://localhost:3100/ | grep -oE '\$ACTION_ID_[0-9a-f]+'); echo "$aid"
$ACTION_ID_408abcac44a30fa2d4d37860df22059e0cc91656ec
$ curl -s -i -X POST -H "Origin: http://localhost:3100" -F "${aid}=" -F 'text=buy milk' http://localhost:3100/ | grep -oE '^HTTP/[0-9.]+ [0-9]+ [A-Za-z ]+|<ul>.*</ul>'
HTTP/1.1 200 OK
<ul><li>buy milk</li></ul>
$ after=$(curl -s http://localhost:3100/ | grep -oE '<ul>.*</ul>'); echo "$after"
<ul><li>buy milk</li></ul>
$ [ "$before" != "$after" ] && echo DIFFERENT
DIFFERENT

$before prints an empty <ul></ul> — this container’s store has never been written to. The POST itself returns 200 OK with the updated list already in its body — curl, not a browser, just submitted this form and Next.js ran addNote for real, which called updateTag("notes"). $before and $after print DIFFERENT: the very next read, a plain follow-up GET, already sees buy milk — no stale cached value in between. As a contrast, send two requests that are each missing one thing the real submission above had: the same multipart body but no $ACTION_ID field, then a body Next.js doesn’t even consider multipart or form-encoded at all:

$ curl -s -i -X POST -H "Origin: http://localhost:3100" -F 'text=missing the action id field' http://localhost:3100/ | grep -oE '^HTTP/[0-9.]+ [0-9]+ [A-Za-z ]+'
HTTP/1.1 400 Bad Request
$ docker logs nextjs-actions-1 2>&1 | grep -o 'Invalid Server Actions request.'
Invalid Server Actions request.
$ curl -s -i -X POST -H "Content-Type: application/json" -d '{"text":"ignored"}' http://localhost:3100/ | grep -oE '^HTTP/[0-9.]+ [0-9]+ [A-Za-z ]+'
HTTP/1.1 405 Method Not Allowed
$ docker rm -f nextjs-actions-1
nextjs-actions-1

These two bad requests fail two different ways, and the gap between them shows what actually decides whether Next.js treats a request as a possible Server Action: its Content-Type, not the hidden field. The first request’s body is still multipart/form-data — the same shape a real form submission sends — just missing $ACTION_ID; Next.js still routes it into the Server Action pipeline and only fails once it gets there, logged as Error: Invalid Server Actions request. and returned as 400 Bad Request. The second request’s application/json body isn’t a shape Next.js treats as a possible Server Action at all, hidden field or not, so it skips that pipeline entirely and falls back to ordinary routing for / — the exact automatic 405 Method Not Allowed Route Handlers showed for an unsupported method on an ordinary route, because a plain page has no POST export of its own.

The Server Validates, the Browser Never Will

Nothing in app/page.tsx stops a browser from submitting an empty text field — there’s no required attribute on the <input> at all. The check that matters is the one already inside addNote, on the server. Start a fresh container on the same build and send both an empty and a whitespace-only value:

$ docker run -d --name nextjs-actions-2 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
e016a934dbe9986180a1efd8d8208316a6e2bf06ef2517d33c993def55d47091
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ before=$(curl -s http://localhost:3100/ | grep -oE '<ul>.*</ul>'); echo "$before"
<ul><li>buy milk</li></ul>
$ aid=$(curl -s http://localhost:3100/ | grep -oE '\$ACTION_ID_[0-9a-f]+')
$ curl -s -i -X POST -H "Origin: http://localhost:3100" -F "${aid}=" -F 'text=' http://localhost:3100/ | grep -oE '^HTTP/[0-9.]+ [0-9]+ [A-Za-z ]+'
HTTP/1.1 500 Internal Server Error
$ curl -s -i -X POST -H "Origin: http://localhost:3100" -F "${aid}=" -F 'text=   ' http://localhost:3100/ | grep -oE '^HTTP/[0-9.]+ [0-9]+ [A-Za-z ]+'
HTTP/1.1 500 Internal Server Error
$ docker logs nextjs-actions-2 2>&1 | tail -4
⨯ Error: text is required
    at h (.next/server/chunks/ssr/[root-of-the-server]__0d8wvt8raloyu._.js:1:203)
    at async m (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:1:12146)
    at async n (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:2:4598)
$ after=$(curl -s http://localhost:3100/ | grep -oE '<ul>.*</ul>'); echo "$after"
<ul><li>buy milk</li></ul>
$ [ "$before" = "$after" ] && echo SAME
SAME
$ docker rm -f nextjs-actions-2
nextjs-actions-2

Both bad requests get a bare 500 Internal Server Error — the same framework-level fallback Route Handlers showed for an uncaught exception. The server’s own log names the real reason, Error: text is required, both times — .trim().length === 0 catches the whitespace-only value exactly like it catches the truly empty one. $before and $after print SAME: neither rejected request ever reached pushNote.

A Real Failure: No “use server”, No Action

Delete "use server" from the top of app/actions.ts, keeping everything else identical. addNote is still a plain, valid async function — predict whether the build even notices:

// app/actions.ts — "use server" removed on purpose
import { updateTag } from "next/cache";
import { pushNote } from "./store";

export async function addNote(formData: FormData) {
  const text = formData.get("text");
  if (typeof text !== "string" || text.trim().length === 0) {
    throw new Error("text is required");
  }
  pushNote(text.trim());
  updateTag("notes");
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 88ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.5s
  Running TypeScript ...
  Finished TypeScript in 3.2s ...
  Collecting page data using 4 workers ...
  Generating static pages using 4 workers (0/3) ...
Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it.
  {action: function h, children: ...}
           ^^^^^^^^^^
    at ignore-listed frames {
  digest: '141319209'
}
Export encountered an error on /page: /, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null

TypeScript has no complaint — addNote still matches the type <form>’s action prop expects. The build fails later, while actually prerendering /: React’s own serializer, which runs whenever a value is about to cross from server-rendered output into what the browser receives, rejects a plain function the moment it isn’t backed by a "use server" reference. The error names “Client Components” generically because that check applies to any function reaching the browser this way, whether or not anything on the page is explicitly marked "use client" — this page never is. Restore the directive to get back to a working build:

// app/actions.ts
"use server";

import { updateTag } from "next/cache";
import { pushNote } from "./store";

export async function addNote(formData: FormData) {
  const text = formData.get("text");
  if (typeof text !== "string" || text.trim().length === 0) {
    throw new Error("text is required");
  }
  pushNote(text.trim());
  updateTag("notes");
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 82ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.0s
  Running TypeScript ...
  Finished TypeScript in 2.1s ...
  Finalizing page optimization ...

Route (app)      Revalidate  Expire
┌ ○ /                   15m      1y
└ ○ /_not-found


○  (Static)  prerendered as static content

Back to a clean ○ build — "use server" isn’t decoration; it’s the one line that turns a plain function into something Next.js is allowed to send across that boundary at all.

Practice

Each task below builds on the files the previous ones (and the main lesson above) left in place — do them in order. Every rebuild below starts the notes list over empty again, exactly like the restore above did — / is fully static, so each fresh build bakes a brand-new snapshot of app/store.ts’s array, which always starts empty (see the FAQ above).

1. Add a second check to addNote: reject text longer than 50 characters, with its own message. Confirm a short note still succeeds and a long one is rejected.

Solution
// app/actions.ts
"use server";

import { updateTag } from "next/cache";
import { pushNote } from "./store";

export async function addNote(formData: FormData) {
  const text = formData.get("text");
  if (typeof text !== "string" || text.trim().length === 0) {
    throw new Error("text is required");
  }
  if (text.trim().length > 50) {
    throw new Error("text must be 50 characters or fewer");
  }
  pushNote(text.trim());
  updateTag("notes");
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 65ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.2s
  Running TypeScript ...
  Finished TypeScript in 2.2s ...
  Finalizing page optimization ...

Route (app)      Revalidate  Expire
┌ ○ /                   15m      1y
└ ○ /_not-found


○  (Static)  prerendered as static content
$ docker run -d --name nextjs-actions-3 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
961b1898d6cb4633225f272568b22371813cd804c71505a5cbe26415a2e21bcf
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ aid=$(curl -s http://localhost:3100/ | grep -oE '\$ACTION_ID_[0-9a-f]+')
$ curl -s -i -X POST -H "Origin: http://localhost:3100" -F "${aid}=" -F 'text=walk the dog' http://localhost:3100/ | grep -oE '^HTTP/[0-9.]+ [0-9]+ [A-Za-z ]+|<ul>.*</ul>'
HTTP/1.1 200 OK
<ul><li>walk the dog</li></ul>
$ longtext='this note is deliberately written to run well past the fifty character limit'; echo -n "$longtext" | wc -c
76
$ curl -s -i -X POST -H "Origin: http://localhost:3100" -F "${aid}=" -F "text=$longtext" http://localhost:3100/ | grep -oE '^HTTP/[0-9.]+ [0-9]+ [A-Za-z ]+'
HTTP/1.1 500 Internal Server Error
$ curl -s http://localhost:3100/ | grep -oc '<li>'
1
$ docker rm -f nextjs-actions-3
nextjs-actions-3

walk the dog is well under the limit and succeeds, 200 OK with the list already showing it. wc -c counts 76 characters in the long note (wc -c counts bytes, one each since the text is plain ASCII) — well past the 50-character limit — and it gets 500 instead, same framework fallback as the empty-text case. grep -oc '<li>' counts exactly one <li> — the rejected note was never added.

2. Reuse the build from Practice 1. Confirm a POST with the wrong Origin header is rejected, a request with no Origin header at all still succeeds, and the list only grows after the second one.

Solution

No file changes — this reuses Practice 1’s build directly.

$ docker run -d --name nextjs-actions-4 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
bd6006cbea2346f48fa665064f0f41927355e5fbd91493bfb4c7102cfc7b7bae
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ before=$(curl -s http://localhost:3100/ | grep -oE '<ul>.*</ul>'); echo "$before"
<ul><li>walk the dog</li></ul>
$ aid=$(curl -s http://localhost:3100/ | grep -oE '\$ACTION_ID_[0-9a-f]+')
$ curl -s -i -X POST -H "Origin: http://evil.example.com" -F "${aid}=" -F 'text=stolen note' http://localhost:3100/ | grep -oE '^HTTP/[0-9.]+ [0-9]+ [A-Za-z ]+'
HTTP/1.1 500 Internal Server Error
$ docker logs nextjs-actions-4 2>&1 | grep -oE '.x-forwarded-host. header with value .*. does not match .origin. header with value .*. from a forwarded Server Actions request. Aborting the action.'
`x-forwarded-host` header with value `localhost:3100` does not match `origin` header with value `evil.example.com` from a forwarded Server Actions request. Aborting the action.
$ mid=$(curl -s http://localhost:3100/ | grep -oE '<ul>.*</ul>'); echo "$mid"
<ul><li>walk the dog</li></ul>
$ [ "$before" = "$mid" ] && echo SAME
SAME
$ curl -s -i -X POST -F "${aid}=" -F 'text=no origin header at all' http://localhost:3100/ | grep -oE '^HTTP/[0-9.]+ [0-9]+ [A-Za-z ]+'
HTTP/1.1 200 OK
$ docker logs nextjs-actions-4 2>&1 | grep -o 'Missing .origin. header from a forwarded Server Actions request.'
Missing `origin` header from a forwarded Server Actions request.
$ after=$(curl -s http://localhost:3100/ | grep -oE '<ul>.*</ul>'); echo "$after"
<ul><li>no origin header at all</li></ul>
$ [ "$before" != "$after" ] && echo DIFFERENT
DIFFERENT
$ docker rm -f nextjs-actions-4
nextjs-actions-4

The mismatched Origin gets a bare 500, and the server’s own log names exactly why: its x-forwarded-host header doesn’t match the Origin header curl sent — a different check than the text is required validation error, even though the client sees a 500 either way; $before and $mid print SAME, confirming nothing was added. Dropping the Origin header entirely is treated differently: the request still succeeds, only logging a warning (Missing \origin` header from a forwarded Server Actions request.) rather than rejecting it — $beforeand$afterprintDIFFERENT`, the new note really was added.

This Origin check is CSRF protection, not authentication — it exists to stop a different site’s page from tricking a victim’s browser into silently submitting this form, not to decide who’s allowed to call addNote. curl here never sent any cookie or credential, and addNote never checked for one; a Server Action is a public POST endpoint exactly like a Route Handler, reachable by anyone who can reach the URL, so any check that actually matters for who may add a note has to live inside the action itself, not rely on this Origin check alone. A missing Origin is let through, not rejected, for a reason Next.js’s own source states directly (a comment in action-handler.js): a request with no Origin header is “a handcrafted request without an origin or a request from an unsafe browser,” and “handcrafted requests can’t contain user credentials that haven’t been shared willingly” — a real browser attaches Origin on its own for every cross-site POST, so the absence of one marks a request no victim’s browser could have produced, not a gap this check is failing to close.

3. Add a second required field, author. Store each note as "author: text" and confirm a submission missing author is rejected.

Solution
// app/actions.ts
"use server";

import { updateTag } from "next/cache";
import { pushNote } from "./store";

export async function addNote(formData: FormData) {
  const author = formData.get("author");
  if (typeof author !== "string" || author.trim().length === 0) {
    throw new Error("author is required");
  }
  const text = formData.get("text");
  if (typeof text !== "string" || text.trim().length === 0) {
    throw new Error("text is required");
  }
  if (text.trim().length > 50) {
    throw new Error("text must be 50 characters or fewer");
  }
  pushNote(`${author.trim()}: ${text.trim()}`);
  updateTag("notes");
}
// app/page.tsx
import { cacheTag } from "next/cache";
import { addNote } from "./actions";
import { getNotesRaw } from "./store";

async function getNotes() {
  "use cache";
  cacheTag("notes");
  return getNotesRaw();
}

export default async function HomePage() {
  const notes = await getNotes();
  return (
    <div>
      <form action={addNote}>
        <input name="author" />
        <input name="text" />
        <button type="submit">Add</button>
      </form>
      <ul>
        {notes.map((n, i) => (
          <li key={i}>{n}</li>
        ))}
      </ul>
    </div>
  );
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 82ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.1s
  Running TypeScript ...
  Finished TypeScript in 2.7s ...
  Finalizing page optimization ...

Route (app)      Revalidate  Expire
┌ ○ /                   15m      1y
└ ○ /_not-found


○  (Static)  prerendered as static content
$ docker run -d --name nextjs-actions-5 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
7ef8d6e67efbcab5c719a4aa0a5e1ba9bbf31a88508033e8c49bc887da1586ce
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ aid=$(curl -s http://localhost:3100/ | grep -oE '\$ACTION_ID_[0-9a-f]+')
$ curl -s -i -X POST -H "Origin: http://localhost:3100" -F "${aid}=" -F 'text=buy milk' http://localhost:3100/ | grep -oE '^HTTP/[0-9.]+ [0-9]+ [A-Za-z ]+'
HTTP/1.1 500 Internal Server Error
$ curl -s -i -X POST -H "Origin: http://localhost:3100" -F "${aid}=" -F 'author=Ada' -F 'text=buy milk' http://localhost:3100/ | grep -oE '^HTTP/[0-9.]+ [0-9]+ [A-Za-z ]+|<ul>.*</ul>'
HTTP/1.1 200 OK
<ul><li>Ada: buy milk</li></ul>
$ curl -s http://localhost:3100/ | grep -oE '<ul>.*</ul>'
<ul><li>Ada: buy milk</li></ul>
$ docker rm -f nextjs-actions-5
nextjs-actions-5

Omitting author fails with 500 before pushNote ever runs — the check for it comes first. Including both fields succeeds, and the stored note reads Ada: buy milk, exactly the "author: text" shape the new code builds. That extra GET right before docker rm -f isn’t just a second confirmation — it’s what gets Ada: buy milk baked into a rendered page on disk, under .next/server/route-cache. updateTag("notes") only marks the cached value for tag "notes" stale; the fresh render only gets computed, and written to that on-disk cache, the next time something reads it (see the FAQ above). What lands on disk is a rendering of the page, not app/store.ts’s own array — that array lives only in this container’s own process memory and disappears the instant the container is removed. Practice 4 shows exactly what that split between a cached rendering and the real store means for what comes back after a restart.

4. Reuse Practice 3’s build without rebuilding. Confirm the one note it left behind is still visible immediately after a plain container restart, then add two more notes and check whether that first note actually survives into the list those two produce. Finish by removing every container and volume this lesson created.

Solution

No file changes — this reuses Practice 3’s build directly.

$ docker run -d --name nextjs-actions-6 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
529e84250b52a4974e47b7e415d1a89acaeec9290d6f25168125b559a44605d9
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ | grep -oE '<ul>.*</ul>'
<ul><li>Ada: buy milk</li></ul>
$ curl -s http://localhost:3100/ | grep -q 'Ada: buy milk' && echo SURVIVED
SURVIVED
$ aid=$(curl -s http://localhost:3100/ | grep -oE '\$ACTION_ID_[0-9a-f]+')
$ curl -s -X POST -H "Origin: http://localhost:3100" -F "${aid}=" -F 'author=Grace' -F 'text=second note' http://localhost:3100/ >/dev/null
$ curl -s -X POST -H "Origin: http://localhost:3100" -F "${aid}=" -F 'author=Linus' -F 'text=third note' http://localhost:3100/ >/dev/null
$ curl -s http://localhost:3100/ | grep -oE '<li>[^/]+/li>'
<li>Grace: second note</li>
<li>Linus: third note</li>
$ curl -s http://localhost:3100/ | grep -q 'Ada: buy milk' || echo GONE
GONE
$ docker rm -f nextjs-actions-1 nextjs-actions-2 nextjs-actions-3 nextjs-actions-4 nextjs-actions-5 nextjs-actions-6
nextjs-actions-6
$ docker volume rm -f nextjs-modules
nextjs-modules

Ada: buy milk really is visible right after the restart — SURVIVED prints because .next/server/route-cache still holds the exact rendering getNotes() produced the last time Practice 3 read it, right before that container stopped (see the FAQ above and Practice 3’s own explanation). But that disk cache holds a rendering, not app/store.ts’s own array — this new container’s copy of that array starts empty like every fresh process does, and it never actually had Ada: buy milk pushed into it. The moment addNote runs again for Grace, updateTag("notes") invalidates the stale cached rendering, and the very next read recomputes getNotes() for real, against this process’s own array — which only has what has actually been pushed here. Ada: buy milk drops out of the list as a result, and GONE confirms it: the restart never actually carried the note forward, it only looked that way until something forced a fresh read. Grace: second note and Linus: third note land in the order they were submitted, in an array that otherwise never saw Ada: buy milk at all. The last two steps remove every container (including the ones earlier sections already removed, which is harmless) and the shared node_modules volume this lesson used.

Frequently Asked Questions

How does a <form action={serverFunction}> actually send data to the server?
Next.js renders the form with action="" and method="POST" (posting to the current page's own URL), plus a hidden input named $ACTION_ID_<hash>, where the hash identifies which exported function to call. With JavaScript disabled, the browser's own native form submission sends a real multipart/form-data POST carrying that hidden field — this lesson proves that path directly with curl. With JavaScript enabled, the browser instead sends a fetch request carrying a next-action header (confirmed present, as a literal string, in the compiled client JavaScript this page loads — part of Next's own shared client runtime, not code specific to this page) and a different body encoding; both paths call the exact same server-side function.
If the browser can validate input before submitting, why does the Server Action check it again on the server?
Because nothing requires a request to come from that form, or from a browser at all. curl, another service, or a browser with JavaScript and HTML validation both disabled can all send the identical POST this lesson's curl commands send. Any check that exists only in the browser (an HTML required attribute, a client-side if) protects nothing, because the server is the only party that sees every request regardless of how it was made — so input has to be validated again on the server even when it was already checked once on the client.
Why did updateTag() work inside this lesson's Server Action when cacheLife and cacheTag showed it failing in a Route Handler?
updateTag() only works inside a Server Action, not a Route Handler — the exact restriction cacheLife and cacheTag's own Route Handler attempt hit, logged as "updateTag can only be called from within a Server Action." This lesson's addNote function is a Server Action by definition (it lives in a file whose first line is "use server"), so the same call that failed there succeeds here, and the caller sees the new value on the very next read — updateTag is designed for exactly that "read your own writes" case, unlike revalidateTag's profile-dependent delay.
What actually decides whether a POST to a page is treated as a Server Action call?
Not the hidden $ACTION_ID_<hash> field by itself. Next.js's own request detection treats any POST whose body is multipart/form-data or application/x-www-form-urlencoded, or that carries a next-action header, as a possible Server Action, before it ever looks for that field. A multipart POST missing the field still enters that pipeline and only fails once it gets there, logged as "Error: Invalid Server Actions request." and returned as a 400 Bad Request — this lesson's own curl commands prove that directly. Only a request whose body isn't one of those shapes at all (plain JSON, say) skips Server Action handling entirely and falls back to ordinary routing, where a plain page has no POST export of its own and gets the generic 405 Method Not Allowed Route Handlers showed for any other unsupported method.
Does restarting the server, or rebuilding it, reset data a Server Action mutated?
The in-memory array always resets on a plain restart; what a reader actually sees after one depends on the cache, not on the array surviving. With the default filesystem cache handler this lesson's self-hosted next start uses, a "use cache" function's result for a given tag is written to an on-disk rendering under .next/server/route-cache only when that function actually recomputes — which updateTag() schedules for the next read of that tag, not at the moment of the mutation itself. That on-disk file is a rendering of the page, not app/store.ts's own array, which lives only in process memory and starts empty in every fresh process regardless of what any earlier process wrote into it. If a read happened before the old container stopped, the next container to start still serves that old rendering straight from disk without re-running the function at all, so the very first read after a restart can show a note that was never actually pushed into the new process's own array. That appearance lasts only until the tag is invalidated again: the moment any further mutation forces a fresh recompute, the result comes from this process's own array, and the earlier note disappears — this lesson's own Practice 3 and Practice 4 demonstrate exactly that sequence. A fresh next build always resets what's visible regardless of any of this, because the route is fully static — the build bakes a brand-new snapshot of whatever app/store.ts's in-memory array holds at that moment, which is always empty on a fresh process.