Emmanuel Akinfulubi

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.

Posting a form without JavaScript

ran at build time · next build + next start · Next 16.3.1

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() and cookies().set in an action

ran at build time · next build + next start · Next 16.3.1

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.

An action id the server has never heard of

ran at build time · next build + next start · Next 16.3.1

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.

'use server' inside a component

shown, not run
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

useActionState and a failed validation, with no JavaScript

ran at build time · next build + next start · Next 16.3.1

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(/&quot;/g, '"').replace(/&#x27;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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.

A valid post

ran at build time · next build + next start · Next 16.3.1

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(/&quot;/g, '"').replace(/&#x27;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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.

useFormStatus: a button that knows its form is busy

shown, not run
'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.

useOptimistic: show the result before the server agrees

shown, not run
'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

GET handlers

ran at build time · next build + next start · Next 16.3.1

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.

POST, DELETE, PATCH, HEAD and OPTIONS

ran at build time · next build + next start · Next 16.3.1

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.

Which handlers the build ran

ran at build time · next build + next start · Next 16.3.1

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.

Same request twice

ran at build time · next build + next start · Next 16.3.1

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.

The proxy in the route table

ran at build time · next build · Next 16.3.1

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.

A redirect and headers

ran at build time · next build + next start · Next 16.3.1

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.

A rewrite in proxy.js

shown, not run
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

cookies(), headers() and searchParams

ran at build time · next build + next start · Next 16.3.1

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.

cookies().set in a route handler

ran at build time · next build + next start · Next 16.3.1

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.

cookies().set in a page

ran at build time · next build + next start · Next 16.3.1

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.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.