Mutating and serving all 5 written
Server Actions and forms, route handlers, proxy.js, and cookies, headers and the async request APIs.
17 Server Actions and forms
A Server Action is a function that runs on the server and is called from a form (or, in the browser, from code). It is how a Next.js app changes data without writing a route handler for each change: you write the function, hand it to a form, and Next.js builds the endpoint, the request and the response. This lesson takes the form apart and posts it by hand, with no JavaScript, so that what is sent is visible.
A form that calls a function
A file that starts with the 'use server' directive exports functions that the
browser may call; every export in it is an action. Lesson 9's 'use client' marks a
boundary for components, and this directive marks one for functions. The page passes an action
to a form as its action, where a plain HTML form would have a URL. The page runs
per request (await connection()) and reads the list of notes from a store that
lives on globalThis, so the action and the page share it. A real app would use a
database there.
The panel reads the page, prints the form, and then does by hand what the browser does when
someone presses the button: a multipart/form-data POST that names the action. It
sends no cookie and loads no script.
app/actions/actions.js
'use server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { addNote } from '../_lib/store.js';
// Anyone who can send a POST can run this function, so it checks its own input.
export async function createNote(formData) {
const title = String(formData.get('title') ?? '').trim();
if (title.length === 0 || title.length > 40) return;
addNote(title);
}
export async function finish() {
const jar = await cookies();
jar.set('finished', 'yes', { path: '/actions', httpOnly: true });
redirect('/actions/done');
}
app/actions/page.js
import { connection } from 'next/server';
import { createNote, finish } from './actions.js';
import { notes } from '../_lib/store.js';
export default async function Actions() {
await connection();
return (
<main data-page="actions">
<ul data-notes>
{notes.map((title, i) => (
<li key={i}>{title}</li>
))}
</ul>
<form action={createNote}>
<input name="title" />
<button type="submit">Add</button>
</form>
<form action={finish}>
<button type="submit">Finish</button>
</form>
</main>
);
}
app/_lib/store.js
// One object for the whole server process, kept on globalThis so that every route that
// imports this file sees the same one. A real app would use a database.
const store = (globalThis.__shelfStore ??= { notes: [], members: [] });
export const notes = store.notes;
export const members = store.members;
export function addNote(title) {
notes.push(title);
}
export function addMember(member) {
members.push(member);
}
form.mjs
const base = process.env.BASE_URL;
const page = await (await fetch(base + '/actions')).text();
const forms = page.match(/<form[\s\S]*?<\/form>/g);
console.log(forms[0]);
const ids = [...page.matchAll(/name="(\$ACTION_ID_[0-9a-f]+)"/g)].map((m) => m[1]);
const send = async (id, fields) => {
const body = new FormData();
body.append(id, '');
for (const [name, value] of Object.entries(fields)) body.append(name, value);
return fetch(base + '/actions', { method: 'POST', body, redirect: 'manual', headers: { origin: base } });
};
const posted = await send(ids[0], { title: 'Read Middlemarch' });
const html = await posted.text();
const empty = await (await send(ids[0], { title: ' ' })).text();
const notes = (text) => [...text.matchAll(/<li>(.*?)<\/li>/g)].map((m) => m[1]);
console.log('POST with a title ->', posted.status, JSON.stringify(notes(html)));
console.log('POST with a blank title ->', JSON.stringify(notes(empty)));
check('the page has one form per action, each with its own id', [forms.length, new Set(ids).size], [2, 2]);
check('the form is a POST of multipart form data, to the same address', ['method="POST"', 'encType="multipart/form-data"', 'action=""'].map((a) => forms[0].includes(a)), [true, true, true]);
check('the id is a name starting with $ACTION_ID_ followed by hex digits', ids.every((id) => /^\$ACTION_ID_[0-9a-f]{20,}$/.test(id)), true);
check('a form post with no JavaScript ran the function', [posted.status, notes(html)], [200, ['Read Middlemarch']]);
check('a blank title was refused by the function itself', notes(empty), ['Read Middlemarch']);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node form.mjs
<form action="" encType="multipart/form-data" method="POST"><input type="hidden" name="$ACTION_ID_<id>"/><input name="title"/><button type="submit">Add</button></form> POST with a title -> 200 ["Read Middlemarch"] POST with a blank title -> ["Read Middlemarch"] ok the page has one form per action, each with its own id ok the form is a POST of multipart form data, to the same address ok the id is a name starting with $ACTION_ID_ followed by hex digits ok a form post with no JavaScript ran the function ok a blank title was refused by the function itself
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The action became an empty string and the form became an ordinary POST, which is
why this works with JavaScript off. The hidden input is the function's address: its name starts
with $ACTION_ID_ and ends in the action's id. The other form has a different one.
The response contained the page rendered again, with the new note in the list, which is why a
successful action usually needs nothing more to show its effect.
The action checked its own input, so the blank title in the second request changed nothing. That is not decoration, as the last panel of this lesson explains.
After the work: redirect and cookies
An action ends in one of two ways: it returns, and the page is shown again, or it calls
redirect() from next/navigation and the browser goes somewhere else.
An action may also write a cookie, which a page cannot (lesson 21). The second form's action
does both.
redirect.mjs
const base = process.env.BASE_URL;
const page = await (await fetch(base + '/actions')).text();
const ids = [...page.matchAll(/name="(\$ACTION_ID_[0-9a-f]+)"/g)].map((m) => m[1]);
const body = new FormData();
body.append(ids[1], '');
const res = await fetch(base + '/actions', { method: 'POST', body, redirect: 'manual', headers: { origin: base } });
const cookie = res.headers.getSetCookie()[0];
console.log('status:', res.status);
console.log('location:', new URL(res.headers.get('location'), base).pathname);
console.log('set-cookie:', cookie);
check('redirect() in an action answers 303 See Other', res.status, 303);
check('and says where to go', new URL(res.headers.get('location'), base).pathname, '/actions/done');
check('cookies().set sent a cookie', cookie.startsWith('finished=yes'), true);
check('with the attributes asked for', ['Path=/actions', 'HttpOnly'].map((a) => cookie.includes(a)), [true, true]);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node redirect.mjs
status: 303 location: /actions/done set-cookie: finished=yes; Path=/actions; HttpOnly ok redirect() in an action answers 303 See Other ok and says where to go ok cookies().set sent a cookie ok with the attributes asked for
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The browser is told to go to the next address with 303 See Other, which turns the POST into a GET of the new page, so reloading the result does not submit the form again. The cookie came in the same response.
A public endpoint
Everything the panel just did, any program can do. An action is an HTTP endpoint with a
generated address, and nothing in the request proves that a form on your page produced it. So
the function must check who is calling and what they sent, which is what createNote
does with the title, and what lessons 26 and 27 do with sessions. The vendored guide
(node_modules/next/dist/docs/01-app/02-guides/server-actions.md) describes the
protections Next.js adds: ids that are encrypted, and unused actions removed from the client
code.
The id also belongs to one build. The same guide says a new deployment typically gets new ids, so a page that is still open in a browser may post an old one, and the server cannot find it. The panel posts an id that never existed to see that answer.
forged.mjs
const base = process.env.BASE_URL;
const forged = new FormData();
forged.append('$ACTION_ID_' + '0'.repeat(40), '');
const res = await fetch(base + '/actions', { method: 'POST', body: forged, headers: { origin: base } });
console.log('a post naming an action that does not exist ->', res.status);
check('the server refused it', res.status >= 400, true);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node forged.mjs
a post naming an action that does not exist -> 500 ok the server refused it
What next start printed meanwhile
⨯ Error: Failed to find Server Action. This request might be from an older or newer deployment.
Read more: https://nextjs.org/docs/messages/failed-to-find-server-action
at ignore-listed frames
⨯ Error: Failed to find Server Action. This request might be from an older or newer deployment.
Read more: https://nextjs.org/docs/messages/failed-to-find-server-action
at ignore-listed frames
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The response was a 500 and the server logged the error, which the panel declared in advance: a log line that reports an error and that the panel did not declare would fail the build.
The other way to write the directive
The directive can also be the first line of a function inside a server component, which keeps a one-off action next to the form that uses it.
export default function Page() {
async function create(formData) {
'use server';
await save(formData.get('title'));
}
return (
<form action={create}>
<input name="title" />
<button type="submit">Add</button>
</form>
);
}
the panels of this lesson use the file form, with the directive at the top of actions.js. The directive can also open the body of a function inside a server component, as here; this version was not run.
18 Forms in practice
The previous lesson called an action and ignored what it returned. A real form has to say what was wrong, keep what was typed, and move on when the input is good. This lesson builds a sign-up form with nothing but React 19 and a function you write yourself, and posts it the way a browser with JavaScript turned off would.
Validation by hand
The rules are an ordinary function in app/_lib/validate.js. It takes the three
fields and returns an object with one message for each field that is wrong, so it can be tested
on its own (lesson 28) and used again. No validation library is needed for three rules, and
zod and its relatives are not part of this course's vendored tree.
The action takes the form's previous state and the new FormData. Wrong input
returns a new state, { errors, values }, and good input saves and redirects. The
form is a client component that calls useActionState, from the
React course: it gives back the current state, a
function to use as the form's action, and whether a submission is in flight. The
state is what the form renders: a message under each field, and the old text as
defaultValue.
Validation by hand
app/_lib/validate.js
// Validation by hand: a pure function, so it can be tested without Next.js (lesson 28).
export function validateSignup({ name, email, password }) {
const errors = {};
if (name.trim().length === 0) errors.name = 'Enter your name.';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Enter an email address like you@example.com.';
if (password.length < 8) errors.password = 'Use at least 8 characters.';
return errors;
}
app/forms/actions.js
'use server';
import { redirect } from 'next/navigation';
import { addMember } from '../_lib/store.js';
import { validateSignup } from '../_lib/validate.js';
export async function signup(previous, formData) {
const values = {
name: String(formData.get('name') ?? ''),
email: String(formData.get('email') ?? ''),
};
const errors = validateSignup({ ...values, password: String(formData.get('password') ?? '') });
if (Object.keys(errors).length > 0) return { errors, values };
addMember({ name: values.name.trim(), email: values.email });
redirect('/forms/thanks');
}
app/forms/SignupForm.js
'use client';
import { useActionState } from 'react';
import { signup } from './actions.js';
const initial = { errors: {}, values: { name: '', email: '' } };
export default function SignupForm() {
const [state, formAction, pending] = useActionState(signup, initial);
return (
<form action={formAction} data-form="signup">
<label>
Name <input name="name" defaultValue={state.values.name} />
</label>
{state.errors.name && <p role="alert" data-error="name">{state.errors.name}</p>}
<label>
Email <input name="email" defaultValue={state.values.email} />
</label>
{state.errors.email && <p role="alert" data-error="email">{state.errors.email}</p>}
<label>
Password <input name="password" type="password" />
</label>
{state.errors.password && <p role="alert" data-error="password">{state.errors.password}</p>}
<button type="submit" disabled={pending}>Sign up</button>
</form>
);
}
app/forms/signup/page.js
import SignupForm from '../SignupForm.js';
export default function Signup() {
return (
<main data-page="signup">
<h1>Sign up</h1>
<SignupForm />
</main>
);
}
invalid.mjs
const base = process.env.BASE_URL;
const decode = (text) => text.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
const hiddenFields = (page) => {
const form = /<form[\s\S]*?<\/form>/.exec(page)[0];
return (form.match(/<input[^>]*type="hidden"[^>]*>/g) || []).map((tag) => [
decode(/name="([^"]*)"/.exec(tag)[1]),
decode(/value="([^"]*)"/.exec(tag)?.[1] ?? ''),
]);
};
const page = await (await fetch(base + '/forms/signup')).text();
const hidden = hiddenFields(page);
console.log('hidden fields in the form:', hidden.map(([name]) => name).join(' '));
const body = new FormData();
for (const [name, value] of hidden) body.append(name, value);
body.append('name', '');
body.append('email', 'nope');
body.append('password', 'short');
const bad = await fetch(base + '/forms/signup', { method: 'POST', body, redirect: 'manual', headers: { origin: base } });
const html = await bad.text();
const errors = [...html.matchAll(/data-error="(\w+)">([^<]*)</g)].map((m) => m[1] + ': ' + m[2]);
console.log('POST with three mistakes ->', bad.status);
console.log(errors.join('\n'));
console.log('the email field kept what was typed:', html.includes('value="nope"'));
console.log('the password field did not:', !/type="password"[^>]*value=/.test(html));
check('the form carries hidden fields that name the action and its state', hidden.map(([name]) => name), ['$ACTION_REF_1', '$ACTION_1:0', '$ACTION_1:1', '$ACTION_KEY']);
check('one of them holds the state the form began with', hidden.some(([name, value]) => name === '$ACTION_1:1' && value.includes('errors')), true);
check('the form posted with no JavaScript came back as a page', bad.status, 200);
check('with a message for each field', errors.length, 3);
check('and what the person typed is still in the field', html.includes('value="nope"'), true);
check('but the password is not sent back', /type="password"[^>]*value=/.test(html), false);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node invalid.mjs
hidden fields in the form: $ACTION_REF_1 $ACTION_1:0 $ACTION_1:1 $ACTION_KEY POST with three mistakes -> 200 name: Enter your name. email: Enter an email address like you@example.com. password: Use at least 8 characters. the email field kept what was typed: true the password field did not: true ok the form carries hidden fields that name the action and its state ok one of them holds the state the form began with ok the form posted with no JavaScript came back as a page ok with a message for each field ok and what the person typed is still in the field ok but the password is not sent back
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The panel posted every hidden field the form had, as a browser does, plus three bad values.
The answer was the sign-up page again, rendered by the server from the state the action
returned: three messages, the email still in its field, and nothing in the password field.
A password is never put back into a page. useActionState added the hidden fields,
and one of them carries the state the form began with, which is how the action receives its
previous state when the request comes from a plain form post.
Success: redirect after the work
When the input is good the action saves the member and calls redirect. It does
not store the password: this lesson is about the form, and lesson 26 does passwords properly.
Redirecting after a successful post means the page the person ends on was fetched with a GET,
so reloading it does not send the form again.
app/forms/thanks/page.js
import { connection } from 'next/server';
import { members } from '../../_lib/store.js';
export default async function Thanks() {
await connection();
return <main data-page="thanks"><p data-members>{`${members.length} member(s) so far.`}</p></main>;
}
valid.mjs
const base = process.env.BASE_URL;
const decode = (text) => text.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
const page = await (await fetch(base + '/forms/signup')).text();
const form = /<form[\s\S]*?<\/form>/.exec(page)[0];
const body = new FormData();
for (const tag of form.match(/<input[^>]*type="hidden"[^>]*>/g)) {
body.append(decode(/name="([^"]*)"/.exec(tag)[1]), decode(/value="([^"]*)"/.exec(tag)?.[1] ?? ''));
}
body.append('name', 'Ada');
body.append('email', 'ada@example.com');
body.append('password', 'correct horse');
const res = await fetch(base + '/forms/signup', { method: 'POST', body, redirect: 'manual', headers: { origin: base } });
const thanks = await (await fetch(base + '/forms/thanks')).text();
console.log('status:', res.status, ' location:', new URL(res.headers.get('location'), base).pathname);
console.log(/<p data-members[^>]*>.*?<\/p>/.exec(thanks)[0]);
check('a valid form redirects with 303', res.status, 303);
check('to the thanks page', new URL(res.headers.get('location'), base).pathname, '/forms/thanks');
check('after the member was saved', /1 member\(s\) so far/.test(thanks), true);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node valid.mjs
status: 303 location: /forms/thanks <p data-members="true">1 member(s) so far.</p> ok a valid form redirects with 303 ok to the thanks page ok after the member was saved
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The 303 and the new member are the two halves of the flow: the work was done before the redirect, not after it.
Two hooks that need a browser
Two more React 19 hooks belong to forms. Neither can be shown by a panel, because both change what the page looks like while a browser waits for the server, and the panels have no browser. The React course covers both, and the code is the same in a Next.js client component.
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Save'}
</button>
);
}
pending is true only while a browser is waiting for the server, and no panel here has a browser. The React course runs this hook in a real one: React 19 actions.
'use client';
import { useOptimistic } from 'react';
export default function Notes({ notes, add }) {
const [shown, addShown] = useOptimistic(notes, (current, title) => [...current, title]);
return (
<form action={async (formData) => {
addShown(formData.get('title'));
await add(formData);
}}>
<ul>{shown.map((title) => <li key={title}>{title}</li>)}</ul>
<input name="title" />
</form>
);
}
the list shows the new note before the server answers; that needs a browser to see. The React course covers the hook: useOptimistic.
19 Route handlers
A page.js answers with HTML. A route.js answers
with whatever you build: JSON for another program, a file, a redirect, an empty 204. It is the
Next.js version of the HTTP server in the Node.js
course, with the routing done by the folder, and it uses the same Request and
Response as fetch (Node.js
course).
GET
A route handler exports one function for each HTTP method it answers, named in capitals. The
function gets the Request; a dynamic folder such as [id] adds a second
argument whose params is a Promise, awaited like a page's (lesson 5).
Response.json(value, init) builds a JSON response, and its status comes from the
second argument.
GET
app/api/books/route.js
import { BOOKS } from '../../_lib/books.js';
export async function GET(request) {
const q = new URL(request.url).searchParams.get('q')?.toLowerCase();
const books = q ? BOOKS.filter((book) => book.title.toLowerCase().includes(q)) : BOOKS;
return Response.json(books);
}
export async function POST(request) {
let body;
try {
body = await request.json();
} catch {
return Response.json({ error: 'the body is not JSON' }, { status: 400 });
}
if (typeof body.title !== 'string' || body.title.trim() === '') {
return Response.json({ error: 'title is required' }, { status: 400 });
}
const book = { slug: body.title.trim().toLowerCase().replace(/\s+/g, '-'), title: body.title.trim() };
return Response.json(book, { status: 201, headers: { Location: `/api/books/${book.slug}` } });
}
app/api/books/[id]/route.js
import { findBook } from '../../../_lib/books.js';
export async function GET(request, { params }) {
const { id } = await params;
const book = findBook(id);
if (!book) return Response.json({ error: `no book called ${id}` }, { status: 404 });
return Response.json(book);
}
export async function DELETE(request, { params }) {
const { id } = await params;
return new Response(null, { status: findBook(id) ? 204 : 404 });
}
get.mjs
const base = process.env.BASE_URL;
const get = async (path) => {
const res = await fetch(base + path);
return { status: res.status, type: res.headers.get('content-type'), body: await res.json() };
};
const all = await get('/api/books');
const some = await get('/api/books?q=dra');
const one = await get('/api/books/persuasion');
const none = await get('/api/books/nope');
console.log('/api/books ', all.status, all.type, all.body.map((b) => b.slug).join(','));
console.log('/api/books?q=dra ', some.status, some.body.map((b) => b.slug).join(','));
console.log('/api/books/persuasion ', one.status, JSON.stringify(one.body));
console.log('/api/books/nope ', none.status, JSON.stringify(none.body));
check('a handler returns what Response.json builds, as JSON', [all.status, all.type, all.body.length], [200, 'application/json', 3]);
check('the query string is on request.url', some.body.map((b) => b.slug), ['dracula']);
check('params is awaited in a handler too', one.body.title, 'Persuasion');
check('a status is chosen by the handler', none.status, 404);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node get.mjs
/api/books 200 application/json middlemarch,dracula,persuasion
/api/books?q=dra 200 dracula
/api/books/persuasion 200 {"slug":"persuasion","title":"Persuasion","author":"Jane Austen"}
/api/books/nope 404 {"error":"no book called nope"}
ok a handler returns what Response.json builds, as JSON
ok the query string is on request.url
ok params is awaited in a handler too
ok a status is chosen by the handler
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
POST, DELETE and the methods nobody wrote
Reading a body is the Request API: await request.json() throws on text
that is not JSON, so the handler catches it and answers 400. Nothing validates the input but
the handler, as with an action (lesson 17). The panel then sends methods the file does not
export, and the answers show what Next.js does on its own.
methods.mjs
const base = process.env.BASE_URL;
const send = (path, init) => fetch(base + path, init);
const json = { 'content-type': 'application/json' };
const created = await send('/api/books', { method: 'POST', headers: json, body: JSON.stringify({ title: 'Frankenstein' }) });
const notJson = await send('/api/books', { method: 'POST', headers: json, body: 'not json' });
const noTitle = await send('/api/books', { method: 'POST', headers: json, body: '{}' });
const removed = await send('/api/books/dracula', { method: 'DELETE' });
const patched = await send('/api/books', { method: 'PATCH' });
const head = await send('/api/books', { method: 'HEAD' });
const options = await send('/api/books', { method: 'OPTIONS' });
console.log('POST valid ->', created.status, created.headers.get('location'), JSON.stringify(await created.json()));
console.log('POST text ->', notJson.status);
console.log('POST {} ->', noTitle.status);
console.log('DELETE ->', removed.status, JSON.stringify(await removed.text()));
console.log('PATCH ->', patched.status);
console.log('HEAD ->', head.status, head.headers.get('content-type'), JSON.stringify(await head.text()));
console.log('OPTIONS ->', options.status, 'allow:', options.headers.get('allow'));
check('POST made something and said where', [created.status, created.headers.get('location')], [201, '/api/books/frankenstein']);
check('a body that is not JSON is refused by the handler', notJson.status, 400);
check('and a body without a title', noTitle.status, 400);
check('DELETE answered 204 with no body', removed.status, 204);
check('a method with no handler is 405', patched.status, 405);
check('HEAD is answered from GET, without a body', [head.status, await head.text()], [200, '']);
check('OPTIONS is answered by Next.js, listing the methods the file exports and HEAD', [options.status, ['GET', 'HEAD', 'OPTIONS', 'POST'].map((m) => options.headers.get('allow').includes(m))], [204, [true, true, true, true]]);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node methods.mjs
POST valid -> 201 /api/books/frankenstein {"slug":"frankenstein","title":"Frankenstein"}
POST text -> 400
POST {} -> 400
DELETE -> 204 ""
PATCH -> 405
HEAD -> 200 application/json ""
OPTIONS -> 204 allow: GET, HEAD, OPTIONS, POST
ok POST made something and said where
ok a body that is not JSON is refused by the handler
ok and a body without a title
ok DELETE answered 204 with no body
ok a method with no handler is 405
ok HEAD is answered from GET, without a body
ok OPTIONS is answered by Next.js, listing the methods the file exports and HEAD
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
A method with no handler is a 405. HEAD needed none: Next.js answered 200 with the JSON
content type and no body. OPTIONS also needed none: it answered 204
and listed in allow the methods the file exports, plus HEAD and
OPTIONS.
Dynamic by default, static on request
A GET handler runs for each request unless you ask otherwise. Adding
export const dynamic = 'force-static' asks Next.js to run it once, during the
build, and keep the response, like a page nothing makes dynamic (lesson 11). The panel reads
the build's prerender manifest to see which handlers ran at build time, and then asks for the
time twice from a handler of each kind.
handlers-table.mjs
import { readFileSync } from 'node:fs';
const manifest = JSON.parse(readFileSync(process.env.APP_DIR + '/.next/prerender-manifest.json', 'utf8'));
const routes = Object.keys(manifest.routes);
console.log('prerendered routes with api in the name:', routes.filter((r) => r.includes('/api/')).join(' '));
check('the force-static handler was run at build time', routes.includes('/api/static'), true);
check('the other handlers were not', ['/api/time', '/api/books', '/api/hello'].map((r) => routes.includes(r)), [false, false, false]);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node handlers-table.mjs
prerendered routes with api in the name: /api/static ok the force-static handler was run at build time ok the other handlers were not
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
app/api/static/route.js
export const dynamic = 'force-static';
export function GET() {
return Response.json({ at: Date.now() });
}
app/api/time/route.js
export function GET() {
return Response.json({ at: Date.now() });
}
static.mjs
const base = process.env.BASE_URL;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const at = async (path) => (await (await fetch(base + path)).json()).at;
const fixed = [await at('/api/static')];
const live = [await at('/api/time')];
await sleep(50);
fixed.push(await at('/api/static'));
live.push(await at('/api/time'));
console.log('/api/static answered with the same time twice:', fixed[0] === fixed[1]);
console.log('/api/time answered with a later time the second time:', live[1] > live[0]);
check('the force-static handler ran once', fixed[0] === fixed[1], true);
check('the default handler ran for each request', live[1] > live[0], true);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node static.mjs
/api/static answered with the same time twice: true /api/time answered with a later time the second time: true ok the force-static handler ran once ok the default handler ran for each request
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The static handler's time did not move. The default one's did. Use force-static
for a response that is the same for everyone, and leave the default for anything that reads the
request or changes over time.
20 proxy.js (formerly middleware)
Some decisions have to be made before any page or handler is looked
for: send this old address to the new one, add a header to everything under a path, turn away a
request that is missing something. In Next.js 16 that code lives in one file at the root of the
project, proxy.js, with a function named proxy. Until version 16
the file was called middleware.js; the vendored reference
(node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md)
says the old name is deprecated and was renamed, and that a proxy runs on the Node.js runtime by
default. The panels below check what they can of that.
The file and where it shows up
The proxy gets a NextRequest and returns a NextResponse that says what
to do: redirect, next to carry on (optionally with changed headers), or
one of the other helpers. The matcher in config lists the addresses it
runs for; for every other address the file is not called. The build lists it in the route
table and compiles it next to the routes.
proxy.js
import { NextResponse } from 'next/server';
export function proxy(request) {
const { pathname } = request.nextUrl;
// Lesson 27: an optimistic check. It looks only for the presence of the session cookie, which
// is cheap, and sends strangers to the login page before any page code runs. It does not
// decide who someone is; the pages and actions do (app/_lib/session.js).
if (pathname.startsWith('/account')) {
if (!request.cookies.has('shelf_session')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
// A redirect to an address that has no page at all: the proxy runs before the routes.
if (pathname === '/proxy-demo/old') {
return NextResponse.redirect(new URL('/proxy-demo/new', request.url));
}
// Pass the request on, with a header added to what the page will see
// and another added to what the browser will get.
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-from-proxy', 'added before the page ran');
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('x-course-proxy', 'ran');
response.headers.set('x-course-url', request.url);
response.headers.set('x-course-runtime', process.versions && process.versions.node ? 'node' : 'other');
return response;
}
export const config = {
matcher: ['/proxy-demo/:path*', '/account/:path*'],
};
proxy-table.mjs
import { readdirSync } from 'node:fs';
const server = readdirSync(process.env.APP_DIR + '/.next/server');
const found = server.filter((name) => /proxy|middleware/i.test(name)).sort();
console.log('files in .next/server with proxy or middleware in the name:', found.join(' '));
check('the build wrote the proxy as middleware.js, the name from before the rename', found.includes('middleware.js'), true);
What next build printed (Next 16.3.1), then the checks, when the build ran it
next build
▲ Next.js 16.3.1 (Turbopack) - Environments: .env.local, .env.production, .env ✓ Running next.config.mjs took <t> Creating an optimized production build ... ✓ Compiled successfully in <t> Running TypeScript ... Finished TypeScript in <t> ... Collecting page data using <n> workers ... ✓ Generating static pages using <n> workers (59/59) in <t> Finalizing page optimization ... Route (app) Revalidate Expire ┌ ○ / ├ ○ /_not-found ├ ○ /about ├ ƒ /account ├ ƒ /actions ├ ○ /actions/done ├ ƒ /api/books ├ ƒ /api/books/[id] ├ ƒ /api/cookie ├ ƒ /api/hello ├ ƒ /api/me ├ ƒ /api/revalidate ├ ○ /api/static ├ ƒ /api/time ├ ƒ /authors/[name] ├ ○ /books ├ /books/[slug] │ ├ ● /books/middlemarch │ ├ ● /books/dracula │ └ ● /books/persuasion ├ ƒ /cache/default ├ ƒ /cache/forced ├ ○ /cache/isr 2s 1y ├ ○ /cache/static ├ ƒ /cache/timed ├ ƒ /cache/unstable ├ ƒ /clock/connection ├ ƒ /clock/cookies ├ ƒ /clock/dynamic ├ ƒ /clock/search ├ ○ /clock/static ├ ○ /counter ├ ○ /dashboard ├ ○ /dashboard/settings ├ ƒ /data/basic ├ ƒ /data/memo ├ ƒ /data/parallel ├ ƒ /data/waterfall ├ ƒ /docs/[...parts] ├ ƒ /env ├ ○ /env/baked ├ ○ /env/client ├ ○ /env/leak ├ ƒ /flaky ├ ○ /forms/signup ├ ƒ /forms/thanks ├ ƒ /guide/[[...parts]] ├ ○ /images ├ ○ /library ├ ƒ /login ├ ○ /meta ├ ƒ /meta/[slug] ├ ○ /moved ├ ○ /old-shelf ├ ○ /pricing ├ ƒ /proxy-demo/new ├ ƒ /reports ├ ƒ /request ├ ƒ /request/bad-set ├ ○ /robots.txt ├ ƒ /signup ├ ○ /sitemap.xml ├ ƒ /streaming ├ ƒ /streaming/blocking ├ ○ /studio ├ ○ /styled ├ ƒ /tags/action ├ ƒ /tags/list └ ○ /tags/path ƒ Proxy (Middleware) ○ (Static) prerendered as static content ● (SSG) prerendered as static HTML (uses generateStaticParams) ƒ (Dynamic) server-rendered on demand ok the log shows "ƒ Proxy (Middleware)" files in .next/server with proxy or middleware in the name: middleware middleware-build-manifest.js middleware-manifest.json middleware.js middleware.js.map middleware.js.nft.json ok the build wrote the proxy as middleware.js, the name from before the rename
The check script ran after the build, with APP_DIR set to the project, and read the files in .next.
The proxy is compiled to .next/server/middleware.js and its manifest, which is
the old name still showing through, and it has a line in the route table of its own.
Redirects, headers and the matcher
The proxy file shown above also starts with a branch for /account, which
lesson 27 explains; it is not used in this lesson. For the demo paths it does two
things, depending on the path. A request to
/proxy-demo/old is redirected to /proxy-demo/new; there is no
old folder, so no page could have answered it, and the redirect shows the proxy ran
first. Anything else under /proxy-demo carries on with
NextResponse.next(), with a header added to the response that the browser
gets and a header added to the request that the page reads with
headers() (lesson 21).
It also adds a header that records whether process.versions.node existed inside
the proxy, and one with request.url. The demo matcher is
/proxy-demo/:path*, so the panel also requests an address under it that no page
answers, and a page outside it.
app/proxy-demo/new/page.js
import { headers } from 'next/headers';
export default async function New() {
const list = await headers();
return (
<main data-page="proxy-new">
<p data-added>{list.get('x-from-proxy') ?? 'no header'}</p>
</main>
);
}
proxy.mjs
const base = process.env.BASE_URL;
const get = (path, init) => fetch(base + path, { redirect: 'manual', ...init });
const old = await get('/proxy-demo/old');
const page = await get('/proxy-demo/new');
const pageHtml = await page.text();
const nothing = await get('/proxy-demo/nothing-here');
const about = await get('/about');
console.log('/proxy-demo/old ', old.status, 'location:', new URL(old.headers.get('location'), base).pathname);
console.log('/proxy-demo/new ', page.status, 'x-course-proxy:', page.headers.get('x-course-proxy'), 'x-course-runtime:', page.headers.get('x-course-runtime'));
console.log(' the page saw the header the proxy added:', pageHtml.match(/<p data-added[^>]*>.*?<\/p>/)[0]);
console.log('request.url inside the proxy:', page.headers.get('x-course-url'), ' (the request was sent to ' + base.replace(/:\d+$/, ':<port>') + ')');
console.log('/proxy-demo/nothing-here', nothing.status, 'x-course-proxy:', nothing.headers.get('x-course-proxy'));
console.log('/about ', about.status, 'x-course-proxy:', about.headers.get('x-course-proxy'));
check('a redirect is decided before any page is looked for', [old.status, new URL(old.headers.get('location'), base).pathname], [307, '/proxy-demo/new']);
check('next() lets the request through with a header on the response', page.headers.get('x-course-proxy'), 'ran');
check('and a header on the request that the page could read', pageHtml.includes('added before the page ran'), true);
check('request.url names loopback addresses as localhost, whatever the request was sent to', [new URL(base).hostname, new URL(page.headers.get('x-course-url')).hostname], ['127.0.0.1', 'localhost']);
check('the proxy runs on Node.js', page.headers.get('x-course-runtime'), 'node');
check('it ran for an address no page answers', [nothing.status, nothing.headers.get('x-course-proxy')], [404, 'ran']);
check('the matcher left other addresses alone', about.headers.get('x-course-proxy'), null);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node proxy.mjs
/proxy-demo/old 307 location: /proxy-demo/new /proxy-demo/new 200 x-course-proxy: ran x-course-runtime: node the page saw the header the proxy added: <p data-added="true">added before the page ran</p> request.url inside the proxy: http://localhost:<port>/proxy-demo/new (the request was sent to http://127.0.0.1:<port>) /proxy-demo/nothing-here 404 x-course-proxy: ran /about 200 x-course-proxy: null ok a redirect is decided before any page is looked for ok next() lets the request through with a header on the response ok and a header on the request that the page could read ok request.url names loopback addresses as localhost, whatever the request was sent to ok the proxy runs on Node.js ok it ran for an address no page answers ok the matcher left other addresses alone
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The redirect is a 307 decided before routing. The page saw a header that the browser never
sent, which is how a proxy passes information on to the page. The 404 for an address with no
page still carried the proxy's header, since the proxy ran before Next.js knew whether there
was a page. And /about carried none, because the matcher did not include it.
The line that prints request.url is the one worth reading twice. The driver sent
the request to 127.0.0.1, and inside the proxy the URL says localhost:
Next.js writes loopback addresses that way. The redirect's Location was built from
request.url and is therefore an address with localhost in it, and the
panel compares only its path.
A rewrite, and why it is not run here
A rewrite is the third thing a proxy usually does: the address in the browser stays the same
and the content comes from another route. While this lesson was written, a rewrite to
new URL('/about', request.url) made Next.js contact a host other than the one the
server listened on, which the network wall of this course refuses by killing the process. That
is why it is a listing. The likely cause is the localhost above: the rewrite target
no longer names the host the server was started on (127.0.0.1), so Next.js treats
it as a request to forward elsewhere. It is not run, so nothing here claims what a rewrite does
on your machine.
import { NextResponse } from 'next/server';
export function proxy(request) {
if (request.nextUrl.pathname === '/proxy-demo/pretty') {
return NextResponse.rewrite(new URL('/about', request.url));
}
return NextResponse.next();
}
export const config = { matcher: ['/proxy-demo/:path*'] };
a rewrite built on request.url was stopped by the network wall of this course when this lesson was written (see the paragraph above), so it is shown and not run.
Because it runs before everything else for the addresses it matches, a proxy is a poor place for slow work or for the only check of who someone is; lesson 27 returns to that.
21 Cookies, headers and the async request APIs
A page that reads the request, which cookie, which header, which query
string, cannot be built ahead of time, since the request is not known yet. In Next.js 16 the
APIs for reading it say so by being asynchronous: you await them (lesson 11 showed
they make a page dynamic). This lesson reads and writes them, and shows where writing is
allowed.
Reading the request
cookies() and headers() come from next/headers and return
Promises, as do the params of lesson 5 and the searchParams a page
receives. The page below holds the Promise that cookies() returns, prints its
typeof then, then awaits it; it awaits headers() and
searchParams the same way. The cookie jar is read with get(name), and the
value is on the .value of what it returns. The headers object is read the way the
Node.js course reads request.headers,
by lower-case name.
Reading the request
app/request/page.js
import { cookies, headers } from 'next/headers';
export default async function Request({ searchParams }) {
// cookies() returns a Promise. Holding it first shows what it is before it is awaited.
const pending = cookies();
const kind = typeof pending.then;
const jar = await pending;
const list = await headers();
const query = await searchParams;
return (
<main data-page="request">
<p data-promise={kind}>{`cookies() returned something whose then is of type ${kind}`}</p>
<p data-theme>{jar.get('theme')?.value ?? 'no theme cookie'}</p>
<p data-header>{list.get('x-demo') ?? 'no x-demo header'}</p>
<p data-query>{query.q ?? 'no q'}</p>
</main>
);
}
read.mjs
const base = process.env.BASE_URL;
const res = await fetch(base + '/request?q=hello', { headers: { cookie: 'theme=light', 'x-demo': 'from the driver' } });
const html = await res.text();
const text = (name) => new RegExp('<p data-' + name + '[^>]*>(.*?)</p>').exec(html)[1];
console.log(text('promise'));
console.log('theme cookie:', text('theme'));
console.log('x-demo header:', text('header'));
console.log('q:', text('query'));
check('cookies() returns a Promise before it is awaited', /data-promise="function"/.test(html), true);
check('the cookie the request sent was read', text('theme'), 'light');
check('and the header', text('header'), 'from the driver');
check('and searchParams, awaited, held the query string', text('query'), 'hello');
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node read.mjs
cookies() returned something whose then is of type function theme cookie: light x-demo header: from the driver q: hello ok cookies() returns a Promise before it is awaited ok the cookie the request sent was read ok and the header ok and searchParams, awaited, held the query string
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
All four came back as the driver sent them. The Promise is a real one: then
exists before the await.
Writing a cookie
A cookie is written with set(name, value, options) on the same object, and what
reaches the browser is a Set-Cookie header. The route handler below sets
theme with four options; the panel prints the names of the attributes in the
header and not their values, since the Expires date is the clock's.
HttpOnly keeps scripts in the browser from reading the cookie, and
SameSite=lax keeps it from most cross-site requests. The panel then sends the cookie
back to a page, which reads it.
app/api/cookie/route.js
import { cookies } from 'next/headers';
export async function GET() {
const jar = await cookies();
jar.set('theme', 'dark', { httpOnly: true, path: '/', maxAge: 3600, sameSite: 'lax' });
return Response.json({ set: 'theme' });
}
write.mjs
const base = process.env.BASE_URL;
const res = await fetch(base + '/api/cookie');
const header = res.headers.getSetCookie()[0];
const parts = header.split('; ').map((part) => part.split('=')[0]);
console.log('set-cookie begins:', header.split(';')[0]);
console.log('and carries these attributes:', parts.slice(1).join(', '));
const back = await (await fetch(base + '/request', { headers: { cookie: header.split(';')[0] } })).text();
check('the route handler sent the cookie', header.startsWith('theme=dark'), true);
check('with the attributes it was given, and an Expires that Next.js added', parts.slice(1), ['Path', 'Expires', 'Max-Age', 'HttpOnly', 'SameSite']);
check('whose values are the ones given', ['Path=/', 'Max-Age=3600', 'SameSite=lax'].map((a) => header.includes(a)), [true, true, true]);
check('and a request that sends it back is read by a page', /<p data-theme[^>]*>dark<\/p>/.test(back), true);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node write.mjs
set-cookie begins: theme=dark and carries these attributes: Path, Expires, Max-Age, HttpOnly, SameSite ok the route handler sent the cookie ok with the attributes it was given, and an Expires that Next.js added ok whose values are the ones given ok and a request that sends it back is read by a page
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The header held the four attributes that were asked for, plus an Expires that
Next.js worked out from Max-Age, in the order Path,
Expires, Max-Age, HttpOnly, SameSite.
Writing a cookie while a page renders
A cookie travels in a response header, and a page that streams (lesson 12) may have sent its headers before a component deeper in the tree runs. Next.js refuses to set one while a page renders: only a Server Action (lesson 17) or a route handler may write one. The page below tries anyway. The server's log is declared in advance and shown with the stack frame names of the build's chunks left in, with the hash in them masked.
app/request/bad-set/page.js
import { cookies } from 'next/headers';
export default async function BadSet() {
const jar = await cookies();
// Not allowed while a page renders: only a Server Action or a route handler may write cookies.
jar.set('theme', 'dark');
return <main>unreachable</main>;
}
bad-set.mjs
const base = process.env.BASE_URL;
const res = await fetch(base + '/request/bad-set');
console.log('status:', res.status);
check('a page that sets a cookie fails', res.status, 500);
check('and sends no cookie', res.headers.getSetCookie().length, 0);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node bad-set.mjs
status: 500 ok a page that sets a cookie fails ok and sends no cookie
What next start printed meanwhile
⨯ Error: Cookies can only be modified in a Server Action or Route Handler. Read more: https://nextjs.org/docs/app/api-reference/functions/cookies#options
at d (.next/server/chunks/ssr/[root-of-the-server]__<hash>._.js:1:268) {
digest: '<digest>'
}
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The page failed with a 500 and the message names the two places that may set cookies. No
header was sent. The log line in the panel is the server's own text; the link in it is Next.js's
pointer to the reference for cookies.