Next.js · 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
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.