GOV.UK Stack, Fast#

Nunjucks, Express and Fastify, GOV.UK Frontend and CASA, for developers who know React and .NET

Compiled bykodebot

GOV.UK Frontend 6.5.0 Express 5.2.1 Fastify 5.12.4 CASA 10.4.0

Read this first#

You build React front ends on ASP.NET Core APIs. Your next project is a UK government service on a different stack. This book maps what you know onto it in about 1 hour 10 minutes, or about 1 hour 25 minutes with the optional part on CASA.

How to read it#

  • No Node experience needed. Chapter 3 teaches Express and Fastify from zero, and chapter 4 and chapter 5 teach them in depth. Chapter 7 and chapter 8 do the same for Nunjucks, chapter 9 covers GOV.UK Frontend, and chapter 15 and chapter 16 cover CASA. Every library links to its own documentation.
  • Read it, then write it. Each depth chapter ends with real code annotated line by line, and a new feature built step by step. Every example runs in the example app's tests.
  • Older code too. Most services you join started on older versions, so the book shows Express 4, Fastify 4 and CASA 9 code, running beside the current versions.
  • Server code is side by side. Express is on the left, Fastify on the right. Both panes come from one working example app, so both run.
  • Pick a language once. The sidebar switches every code pane between TypeScript and JavaScript. The JavaScript is generated from the TypeScript and passes the same tests.
  • Figures carry facts. Some facts appear only in a figure. Each figure has an "As text" version under it.
  • Every chapter ends "On the job". "Find it" is for a repository you have joined; "Add it" is for a service you are starting. Chapter 13 collects both lists.
  • Search with ⌘K. Type a React or .NET term, such as useState or ModelState, to land on its GOV.UK answer.

What it covers#

The facts were checked on 11 September 2026 against GOV.UK Frontend 6.5.0, Express 5.2.1, Fastify 5.12.4, Nunjucks 3.2.4, CASA 10.4.0 and TypeScript 7.0.2, on Node 22 or later.

The example is a fictional service, "Apply for a juggling licence", in the book's specimen folder. Run it locally, but never deploy it: it renders the GOV.UK header, which only services on GOV.UK domains may use (Design System: GOV.UK header).

The stack on one page#

Part I · The shift · Chapter 01·3 min read

What each piece of a GOV.UK Node service does, and which of your React and .NET skills it replaces.

On your current projects the browser does most of the work. A React app owns routing, state and forms, and calls your ASP.NET Core API for data. A GOV.UK service moves that work back to a server. The browser gets finished HTML, and every step of a form is a round trip to the server.

What moves where: the single-page app's job moves to a Node frontend, and the API behind it can stay. Left, what a full-stack React and .NET developer builds today: a React single-page app in the browser calls an ASP.NET Core API with fetch and JSON, which needs CORS and a bearer token, and the API talks to a database. Right, a GOV.UK service: the browser gets plain HTML that works with JavaScript turned off. It sends GET requests for pages and POST requests for forms, with a session cookie and a CSRF token, to a Node frontend built with Express or Fastify that holds the routes, the session and the Nunjucks templates. The frontend calls a backend API server to server with JSON, and that API may be your existing .NET, unchanged. The single-page app's job moves to a server; your API can stay. WHAT YOU BUILD TODAY ON A GOV.UK SERVICE Browser React single-page app routing, state, forms fetch JSON CORS, bearer token ASP.NET Core API Database Browser plain HTML, a little JavaScript works with JavaScript off GET page, POST form cookie + CSRF token Node frontend Express or Fastify routes, session, Nunjucks JSON, server to server Backend API maybe your .NET, unchanged Database the app's job moves to a server your API can stay
Figure What moves where: the single-page app's job moves to a Node frontend, and the API behind it can stay.#
As text

Left, what a full-stack React and .NET developer builds today: a React single-page app in the browser calls an ASP.NET Core API with fetch and JSON, which needs CORS and a bearer token, and the API talks to a database. Right, a GOV.UK service: the browser gets plain HTML that works with JavaScript turned off. It sends GET requests for pages and POST requests for forms, with a session cookie and a CSRF token, to a Node frontend built with Express or Fastify that holds the routes, the session and the Nunjucks templates. The frontend calls a backend API server to server with JSON, and that API may be your existing .NET, unchanged. The single-page app's job moves to a server; your API can stay.

The Node frontend is a small web server. It renders pages from templates, keeps each user's answers in a session, and sends the finished application to a backend API. That API is often written in another language, and it may be the .NET you already write.

You do not need to know any of these tools yet. Here is what happens inside the Node frontend when a browser asks for a page; the next chapters take each part in turn.

One request inside a Node frontend: the framework runs its middleware, calls your route handler, and Nunjucks turns a template and data into HTMLBrowserFrameworkYour codeNunjucksGET /name1middleware or hooks: headers, session2the route handler for GET/name3render pages/name.njk withthe data4the template calls GOV.UK Frontend macros5HTML6send the HTML7200, the page8
Figure One request inside a Node frontend: the framework runs its middleware, calls your route handler, and Nunjucks turns a template and data into HTML#
As text
  1. Browser to Framework: GET /name
  2. Framework: middleware or hooks: headers, session
  3. Framework to Your code: the route handler for GET /name
  4. Your code to Nunjucks: render pages/name.njk with the data
  5. Nunjucks: the template calls GOV.UK Frontend macros
  6. Nunjucks to Your code: HTML
  7. Your code to Framework: send the HTML
  8. Framework to Browser: 200, the page

The pieces#

PieceWhat it doesYou know it as
Express or Fastifythe web server: routes, middleware or plugins, sessionsASP.NET Core
Nunjucksserver-side templates with layouts, blocks and macrosRazor, or JSX that never reaches the browser
GOV.UK Frontendthe Design System's components as Nunjucks macros, with CSS and a little JavaScripta component library
GOV.UK Design Systemthe patterns: question pages, check answers, error messagesyour design system's guidance
Service Standardthe 14 points a government service is assessed againsta quality gate you cannot skip
CASA (optional)DWP's framework that runs a whole form journey from configurationa wizard component, on the server

Your skills carry over one layer at a time. React knowledge maps onto templates and components. .NET knowledge maps onto the server, where a GOV.UK page works much like a Razor Page with an OnGet and an OnPost.

Where your skills land: React skills map onto templates and components, .NET skills onto the server, and the standards are new to both. Where your skills land. From React: JSX components become Nunjucks templates and macros; a component library becomes GOV.UK Frontend's components; useState and React Router become the session and server routes. From .NET: the ASP.NET Core middleware pipeline becomes Express's chain or Fastify's plugins; Razor Pages' OnGet and OnPost become GET a page, POST a form, redirect; ModelState errors become the error summary and field errors. New to both: the Service Standard, WCAG 2.2 AA, progressive enhancement and CASA. YOU KNOW WHERE IT LANDS REACT JSX components Nunjucks templates and macros a component library GOV.UK Frontend components useState and React Router the session and server routes .NET the middleware pipeline Express chain or Fastify plugins Razor Pages OnGet and OnPost GET a page, POST a form, redirect ModelState errors error summary and field errors New to both: the Service Standard, WCAG 2.2 AA, progressive enhancement, CASA
Figure Where your skills land: React skills map onto templates and components, .NET skills onto the server, and the standards are new to both.#
As text

Where your skills land. From React: JSX components become Nunjucks templates and macros; a component library becomes GOV.UK Frontend's components; useState and React Router become the session and server routes. From .NET: the ASP.NET Core middleware pipeline becomes Express's chain or Fastify's plugins; Razor Pages' OnGet and OnPost become GET a page, POST a form, redirect; ModelState errors become the error summary and field errors. New to both: the Service Standard, WCAG 2.2 AA, progressive enhancement and CASA.

On the job#
Find it
Open package.json. express or fastify names the server, the govuk-frontend version names the components, and @dwp/govuk-casa means the journey is configured rather than written by hand.
Add it
Start from three packages: a server (Express or Fastify), nunjucks and govuk-frontend. The rest of this book adds the plumbing around them.

The request loop#

Part I · The shift · Chapter 02·3 min read

Every GOV.UK question page runs the same loop: GET the page, POST the form, then show the errors or save and redirect. This loop replaces your client-side state.

The request loop for one question page: a failed submission, then a successful oneBrowserServerSessionGET /name1200: the page, with a form2POST /name, empty3validate: fails4200: the same page, with errors5POST /name, Ada Lovelace6validate: passes7save the answer8302: go to /email9GET /email10
Figure The request loop for one question page: a failed submission, then a successful one#
As text
  1. Browser to Server: GET /name
  2. Server to Browser: 200: the page, with a form
  3. Browser to Server: POST /name, empty
  4. Server: validate: fails
  5. Server to Browser: 200: the same page, with errors
  6. Browser to Server: POST /name, Ada Lovelace
  7. Server: validate: passes
  8. Server to Session: save the answer
  9. Server to Browser: 302: go to /email
  10. Browser to Server: GET /email

Read the figure as three rules. A GET only shows a page. A POST checks the answer. If the answer is wrong, the POST renders the same page again with the errors. If it is right, the POST saves it and redirects. The redirect matters: refreshing the next page repeats a harmless GET, not the form submission. The pattern is often called Post/Redirect/Get.

Why redirect after a POST: refreshing after a redirect repeats only a GET, where a page rendered straight from a POST would send the form againBrowserServerPOST /check-answers1302: go to /confirmation2GET /confirmation3200: the confirmation page4the user presses refresh5GET /confirmation again: harmless6
Figure Why redirect after a POST: refreshing after a redirect repeats only a GET, where a page rendered straight from a POST would send the form again#
As text
  1. Browser to Server: POST /check-answers
  2. Server to Browser: 302: go to /confirmation
  3. Browser to Server: GET /confirmation
  4. Server to Browser: 200: the confirmation page
  5. Browser: the user presses refresh
  6. Browser to Server: GET /confirmation again: harmless

State lives in the session#

In React, answers live in component state until you send them. Here the browser keeps nothing. Each answer goes into a server-side session as soon as its page is valid, and a cookie tells the server whose session it is. The next page, the check answers page and the final submission all read from that session.

React (JSX)you know this
function NamePage() {
  const [name, setName] = useState('');
  const [error, setError] = useState('');
  async function onSubmit(e) {
    e.preventDefault();
    if (!name) return setError('Enter your full name');
    await fetch('/api/name', { method: 'POST', body: name });
    navigate('/email');
  }
  return (
    <form onSubmit={onSubmit}>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      {error && <p>{error}</p>}
      <button>Continue</button>
    </form>
  );
}
Nunjucks
<form method="post" novalidate>
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
  {{ govukInput({
    label: { text: "What is your full name?" },
    id: "fullName",
    name: "fullName",
    value: values.fullName,
    errorMessage: fieldErrors.fullName
  }) }}
  {{ govukButton({ text: "Continue" }) }}
</form>

The template has no handlers. The check, the save and the navigation all moved into the POST route, which is the same in both frameworks apart from its wiring:

ASP.NET Core Like a Razor Pages OnPost handler that returns Page() or RedirectToPage().
Expressexpress/routes.tsexpress/routes.jspost-page
router.post('/:page', (req, res, next) => {
  const { page } = req.params;
  if (!isQuestion(page)) return next();
  const answers = req.session.answers ?? {};
  const changing = req.query.change === 'true';
  const values = pick(page, req.body);
  const errors = validate(page, values);
  if (errors.length > 0) {
    return res.render(`pages/${page}`, {
      values,
      ...errorView(errors),
      backLink: backLink(page, answers, changing),
    });
  }
  req.session.answers = { ...answers, [page]: values };
  res.redirect(
    `/${nextPage(page, req.session.answers, changing)}`,
  );
});
router.post('/:page', (req, res, next) => {
  const { page } = req.params;
  if (!isQuestion(page)) return next();
  const answers = req.session.answers ?? {};
  const changing = req.query.change === 'true';
  const values = pick(page, req.body);
  const errors = validate(page, values);
  if (errors.length > 0) {
    return res.render(`pages/${page}`, {
      values,
      ...errorView(errors),
      backLink: backLink(page, answers, changing),
    });
  }
  req.session.answers = { ...answers, [page]: values };
  res.redirect(
    `/${nextPage(page, req.session.answers, changing)}`,
  );
});
Fastifyfastify/routes.tsfastify/routes.jspost-page
app.post<PageRequest>('/:page', async (request, reply) => {
  const { page } = request.params;
  if (!isQuestion(page)) return reply.callNotFound();
  const answers = request.session.answers ?? {};
  const changing = request.query.change === 'true';
  const values = pick(page, request.body);
  const errors = validate(page, values);
  if (errors.length > 0) {
    return reply.view(`pages/${page}`, {
      values,
      ...errorView(errors),
      backLink: backLink(page, answers, changing),
    });
  }
  request.session.answers = { ...answers, [page]: values };
  return reply.redirect(
    `/${nextPage(page, request.session.answers, changing)}`,
  );
});
app.post('/:page', async (request, reply) => {
  const { page } = request.params;
  if (!isQuestion(page)) return reply.callNotFound();
  const answers = request.session.answers ?? {};
  const changing = request.query.change === 'true';
  const values = pick(page, request.body);
  const errors = validate(page, values);
  if (errors.length > 0) {
    return reply.view(`pages/${page}`, {
      values,
      ...errorView(errors),
      backLink: backLink(page, answers, changing),
    });
  }
  request.session.answers = { ...answers, [page]: values };
  return reply.redirect(
    `/${nextPage(page, request.session.answers, changing)}`,
  );
});

CORS leaves, CSRF arrives#

Pages and forms now come from the same origin as the server, so cross-origin resource sharing (CORS) has nothing to do. But the browser sends the session cookie with every request to the service, including a form post that another site starts. That attack is cross-site request forgery (CSRF). Every form therefore carries a hidden token, which the server checks on every POST. Chapter 6 wires it up.

CORS leaves, CSRF arrives: same-origin forms need no CORS, but the automatic session cookie needs a CSRF token. Left, a single-page app on app.example.com calls an API on api.example.com: a different origin, so the API must send CORS headers. The bearer token travels only when your JavaScript adds it, so another site cannot borrow it and CSRF is a small risk. Right, a GOV.UK service: the browser posts forms to the same origin, so there is no CORS, but the browser sends the session cookie with every request to the service automatically. A page on another site, evil.example, can post a form to the service and the cookie rides along. The frontend stops it by checking a CSRF token that only its own forms carry, and rejects the forged post with a 403. SINGLE-PAGE APP AND API GOV.UK SERVICE Browser app.example.com different origin: CORS headers needed API api.example.com Your JavaScript adds the token to each call, so another site cannot borrow it. Low CSRF risk. Browser service.example.com same origin: no CORS cookie sent automatically Node frontend checks the CSRF token on every POST A page on evil.example forged POST, cookie rides along: no token, rejected 403
Figure CORS leaves, CSRF arrives: same-origin forms need no CORS, but the automatic session cookie needs a CSRF token.#
As text

Left, a single-page app on app.example.com calls an API on api.example.com: a different origin, so the API must send CORS headers. The bearer token travels only when your JavaScript adds it, so another site cannot borrow it and CSRF is a small risk. Right, a GOV.UK service: the browser posts forms to the same origin, so there is no CORS, but the browser sends the session cookie with every request to the service automatically. A page on another site, evil.example, can post a form to the service and the cookie rides along. The frontend stops it by checking a CSRF token that only its own forms carry, and rejects the forged post with a 403.

It works without JavaScript#

The Service Manual says every government service must follow progressive enhancement, and tells teams not to build a single-page application (Service Manual: using progressive enhancement). So every page must work with plain HTML forms. GOV.UK Frontend's JavaScript only improves components that already work, for example by moving focus to the error summary.

On the job#
Find it
Pick a question page and find its two routes: a GET that renders the template, and a POST that validates, then renders again or redirects.
Add it
Give every question page a GET and a POST. Save valid answers in the session, and always redirect after a successful POST.

Express and Fastify, from zero#

Part II · The server · Chapter 03·4 min read

Express and Fastify are the two Node web frameworks this book covers. This chapter starts from nothing: how each one turns a request into a response, then how their shapes differ.

A first app in each#

Node runs JavaScript on a server. A web framework listens for HTTP requests, matches each request's method and path to a function you wrote, and sends back what that function produces. You install a framework from npm and import it like any other module: npm install express or npm install fastify. Here is a small app in each, doing the same three things:

ASP.NET Core Like a minimal API: middleware added with app.Use, routes added with app.MapGet.
Expresshello/express.tshello/express.jshello
const app = express();

app.use((req, res, next) => {
  res.set('Cache-Control', 'no-store');
  next();
});

app.get('/', (req, res) => {
  res.send('Hello from Express');
});

app.get('/pages/:page', (req, res) => {
  res.json({
    page: req.params.page,
    change: req.query.change,
  });
});
const app = express();

app.use((req, res, next) => {
  res.set('Cache-Control', 'no-store');
  next();
});

app.get('/', (req, res) => {
  res.send('Hello from Express');
});

app.get('/pages/:page', (req, res) => {
  res.json({
    page: req.params.page,
    change: req.query.change,
  });
});
Fastifyhello/fastify.tshello/fastify.jshello
const app = Fastify();

app.addHook('onRequest', async (request, reply) => {
  reply.header('Cache-Control', 'no-store');
});

app.get('/', async () => 'Hello from Fastify');

app.get<{
  Params: { page: string };
  Querystring: { change?: string };
}>('/pages/:page', async (request) => ({
  page: request.params.page,
  change: request.query.change,
}));
const app = Fastify();

app.addHook('onRequest', async (request, reply) => {
  reply.header('Cache-Control', 'no-store');
});

app.get('/', async () => 'Hello from Fastify');

app.get('/pages/:page', async (request) => ({
  page: request.params.page,
  change: request.query.change,
}));
  • The app. express() and Fastify() each create one, and everything else is added to it.
  • Code that runs for every request. Express calls it middleware: a function that gets the request, the response and next, and calls next() to pass the request on. Fastify calls it a hook, such as onRequest, which runs at a named point in every request.
  • A route. app.get(path, handler) in both. :page in the path arrives as req.params.page, and ?change=true as req.query.change. Fastify names the two objects request and reply, where Express names them req and res.
  • The answer. An Express handler calls a method on res, such as send, json, render or redirect, and nothing is sent until it does. A Fastify handler can simply return a value: Fastify sends a string as plain text and an object as JSON.
Gotcha
Express sends a string as HTML. res.send('...') answers with text/html, so never put user input into it. Render a template instead, because a template escapes its output.

To start an app, tell it to listen on a port. The example app keeps this in a small server file, so its tests can start the same app on any free port:

ASP.NET Core Like app.Run() at the end of Program.cs.
Expressexpress/server.tsexpress/server.jslisten
createApp().listen(port, () => {
  console.log(`Express version: http://localhost:${port}`);
});
createApp().listen(port, () => {
  console.log(`Express version: http://localhost:${port}`);
});
Fastifyfastify/server.tsfastify/server.jslisten
const app = await buildApp();
await app.listen({ port });
const app = await buildApp();
await app.listen({ port });

In the example app, npm run express or npm run fastify starts each version. Node 22 needs --experimental-strip-types to run TypeScript directly, and those scripts pass it for you.

The two shapes#

Express runs one ordered chain of middleware; Fastify registers a tree of plugins, and fastify-plugin shares a plugin with the whole app. Top, Express: one ordered chain. A request passes helmet, the urlencoded body parser, the session and CSRF middleware, the routes, then the 404 and error handlers, in the order app.use added them; each step calls next() to hand on. This is the same idea as the ASP.NET Core middleware pipeline. Bottom, Fastify: a tree of plugins under the root instance. Helmet, formbody, and cookie, session and CSRF are wrapped with fastify-plugin, so their decorators and hooks are shared with the whole app. The journeyRoutes plugin is a plain plugin with its own scope: hooks added inside it stay inside it. EXPRESS: ONE ORDERED CHAIN request helmet urlencoded session, csrf routes 404, errors app.use(fn) adds a step. Every request walks the steps in the order you wrote them; next() hands on. The same idea as the ASP.NET Core middleware pipeline. FASTIFY: A TREE OF PLUGINS root instance helmet formbody cookie, session, csrf journeyRoutes wrapped with fastify-plugin Their decorators and hooks are shared with the whole app, so every route gets helmet, a parsed body, a session and CSRF checks. a plain plugin: its own scope. Hooks added inside it stay inside it.
Figure Express runs one ordered chain of middleware; Fastify registers a tree of plugins, and fastify-plugin shares a plugin with the whole app.#
As text

Top, Express: one ordered chain. A request passes helmet, the urlencoded body parser, the session and CSRF middleware, the routes, then the 404 and error handlers, in the order app.use added them; each step calls next() to hand on. This is the same idea as the ASP.NET Core middleware pipeline. Bottom, Fastify: a tree of plugins under the root instance. Helmet, formbody, and cookie, session and CSRF are wrapped with fastify-plugin, so their decorators and hooks are shared with the whole app. The journeyRoutes plugin is a plain plugin with its own scope: hooks added inside it stay inside it.

If you know the ASP.NET Core middleware pipeline, Express will feel familiar. Each app.use() adds a step, and every request walks the steps in the order you added them. Fastify registers plugins instead. A plain plugin gets its own scope, so the hooks and decorators added inside it stay inside it. A decorator is an extra method, such as reply.view. The official @fastify/* plugins are wrapped with fastify-plugin, which shares them with the whole app.

Setting up templates#

Both apps start by telling Nunjucks where templates live, then add a global that every page can use.

ASP.NET Core Like registering Razor view locations once, at startup.
Expressexpress/app.tsexpress/app.jsviews
const env = nunjucks.configure(
  [paths.govukViews, paths.views],
  { express: app },
);
env.addGlobal('serviceName', serviceName);
app.set('view engine', 'njk');
const env = nunjucks.configure(
  [paths.govukViews, paths.views],
  { express: app },
);
env.addGlobal('serviceName', serviceName);
app.set('view engine', 'njk');
Fastifyfastify/app.tsfastify/app.jsviews
await app.register(fastifyView, {
  engine: { nunjucks },
  templates: [paths.govukViews, paths.views],
  viewExt: 'njk',
  options: {
    onConfigure: (env: nunjucks.Environment) =>
      env.addGlobal('serviceName', serviceName),
  },
});
await app.register(fastifyView, {
  engine: { nunjucks },
  templates: [paths.govukViews, paths.views],
  viewExt: 'njk',
  options: {
    onConfigure: (env) =>
      env.addGlobal('serviceName', serviceName),
  },
});

A route#

One GET route serves every question page. It refuses pages the user has not reached yet, then renders the template with the saved answer.

ASP.NET Core Like a Razor Pages OnGet handler.
Expressexpress/routes.tsexpress/routes.jsget-page
router.get('/:page', (req, res, next) => {
  const { page } = req.params;
  if (!isQuestion(page)) return next();
  const answers = req.session.answers ?? {};
  const pages = route(answers);
  if (!pages.includes(page)) {
    return res.redirect(`/${pages.at(-1)}`);
  }
  const changing = req.query.change === 'true';
  res.render(`pages/${page}`, {
    values: answers[page] ?? {},
    backLink: backLink(page, answers, changing),
  });
});
router.get('/:page', (req, res, next) => {
  const { page } = req.params;
  if (!isQuestion(page)) return next();
  const answers = req.session.answers ?? {};
  const pages = route(answers);
  if (!pages.includes(page)) {
    return res.redirect(`/${pages.at(-1)}`);
  }
  const changing = req.query.change === 'true';
  res.render(`pages/${page}`, {
    values: answers[page] ?? {},
    backLink: backLink(page, answers, changing),
  });
});
Fastifyfastify/routes.tsfastify/routes.jsget-page
app.get<PageRequest>('/:page', async (request, reply) => {
  const { page } = request.params;
  if (!isQuestion(page)) return reply.callNotFound();
  const answers = request.session.answers ?? {};
  const pages = route(answers);
  if (!pages.includes(page)) {
    return reply.redirect(`/${pages.at(-1)}`);
  }
  const changing = request.query.change === 'true';
  return reply.view(`pages/${page}`, {
    values: answers[page] ?? {},
    backLink: backLink(page, answers, changing),
  });
});
app.get('/:page', async (request, reply) => {
  const { page } = request.params;
  if (!isQuestion(page)) return reply.callNotFound();
  const answers = request.session.answers ?? {};
  const pages = route(answers);
  if (!pages.includes(page)) {
    return reply.redirect(`/${pages.at(-1)}`);
  }
  const changing = request.query.change === 'true';
  return reply.view(`pages/${page}`, {
    values: answers[page] ?? {},
    backLink: backLink(page, answers, changing),
  });
});
TypeScript only

Fastify types a route through a generic. The route above passes PageRequest, so request.params.page is a string and request.query.change is optional. Express reads the parameter names from the path string instead.

fastify/routes.tsrequest-type
type PageRequest = {
  Params: { page: string };
  Querystring: { change?: string };
};

The same idea, three names#

IdeaExpressFastifyASP.NET Core
create the appexpress()Fastify()WebApplication.CreateBuilder()
add shared behaviourapp.use(fn)app.register(plugin), app.addHook()app.Use(...)
a routerouter.get('/:page', fn)app.get('/:page', fn)MapGet, a Razor Page
path parameterreq.params.pagerequest.params.pagea route value
query stringreq.query.changerequest.query.changea query value
form bodyreq.bodyrequest.bodymodel binding
render a templateres.render('pages/name')reply.view('pages/name')return Page()
redirectres.redirect('/email')reply.redirect('/email')RedirectToPage()
not founda last app.use(fn)setNotFoundHandlerUseStatusCodePages
errorsapp.use((err, req, res, next) => ...)setErrorHandlerUseExceptionHandler
Gotcha
Fastify's register is asynchronous and scoped. Await each app.register() in order, as the example does, so each plugin is ready before the next one uses it. And a hook added inside a plain plugin applies only to that plugin's routes.

A request's life in Fastify#

Fastify names the points where you can hook in. Their order tells you where each piece of plumbing belongs.

Fastify's request lifecycle: hooks run in this order, and the body is only parsed after preParsingonRequest: no body yetpreParsing, then the body is parsedpreValidation, then any schemavalidationpreHandler: body and session readythe route handlerpreSerialization, then onSendonResponse: the response has gone
Figure Fastify's request lifecycle: hooks run in this order, and the body is only parsed after preParsing#
As text
  1. onRequest: no body yet
  2. preParsing, then the body is parsed
  3. preValidation, then any schema validation
  4. preHandler: body and session ready
  5. the route handler
  6. preSerialization, then onSend
  7. onResponse: the response has gone
On the job#
Find it
Find where the app is created: express() or Fastify(). Read down from there. In Express, the order of app.use calls is the order a request runs. In Fastify, read each register and addHook.
Add it
Choose one framework per service. CASA needs Express; Fastify gives you typed routes, schemas and scoped plugins.

Routes, requests and responses#

Part II · The server · Chapter 04·11 min read

Every routing, request and response feature you will meet in Express and Fastify code, side by side, each from a tested example. The chapter ends with a plugin to read and a page to write.

Methods and routes#

A route is an HTTP method, a path and a handler. Both frameworks have a method for each HTTP method, such as get and post, and all for every method at once. Express's app.route() puts several methods on one path. Fastify's app.route() takes the whole declaration in one object. Its shorthands, such as app.get(), take the same options as their second argument.

ASP.NET Core Like MapGet, MapPost and MapMethods in a minimal API.
Expressdepth/express/routes.tsdepth/express/routes.jsmethods
app
  .route('/licences')
  .get((req, res) => {
    res.send('list the licences');
  })
  .post((req, res) => {
    res.status(201).send('create a licence');
  });
app.all('/ping', (req, res) => {
  res.send(`${req.method} ping`);
});
app
  .route('/licences')
  .get((req, res) => {
    res.send('list the licences');
  })
  .post((req, res) => {
    res.status(201).send('create a licence');
  });
app.all('/ping', (req, res) => {
  res.send(`${req.method} ping`);
});
Fastifydepth/fastify/routes.tsdepth/fastify/routes.jsmethods
app.route({
  method: 'GET',
  url: '/licences',
  handler: async () => 'list the licences',
});
app.post('/licences', async (request, reply) => {
  reply.code(201);
  return 'create a licence';
});
app.all(
  '/ping',
  async (request) => `${request.method} ping`,
);
app.route({
  method: 'GET',
  url: '/licences',
  handler: async () => 'list the licences',
});
app.post('/licences', async (request, reply) => {
  reply.code(201);
  return 'create a licence';
});
app.all(
  '/ping',
  async (request) => `${request.method} ping`,
);

Path syntax#

Both frameworks mark a path parameter with a colon. They differ on optional parts, wildcards and patterns. Express 4 differs again, as chapter 5 shows.

ASP.NET Core Like route templates such as {id}, {term?} and {**path}.
Expressdepth/express/routes.tsdepth/express/routes.jspaths
// a named parameter: /licences/42 gives { id: '42' }
app.get('/licences/:id', (req, res) => {
  res.json(req.params);
});
// an optional part, in braces: /search and /search/fire
app.get('/search{/:term}', (req, res) => {
  res.json(req.params);
});
// a named wildcard: /docs/a/b gives { path: ['a', 'b'] }
app.get('/docs/*path', (req, res) => {
  res.json(req.params);
});
// no regular expressions in Express 5 paths: check the value instead
app.get('/years/:year', (req, res, next) => {
  if (!/^\d{4}$/.test(req.params.year)) return next();
  res.json(req.params);
});
// a named parameter: /licences/42 gives { id: '42' }
app.get('/licences/:id', (req, res) => {
  res.json(req.params);
});
// an optional part, in braces: /search and /search/fire
app.get('/search{/:term}', (req, res) => {
  res.json(req.params);
});
// a named wildcard: /docs/a/b gives { path: ['a', 'b'] }
app.get('/docs/*path', (req, res) => {
  res.json(req.params);
});
// no regular expressions in Express 5 paths: check the value instead
app.get('/years/:year', (req, res, next) => {
  if (!/^\d{4}$/.test(req.params.year)) return next();
  res.json(req.params);
});
Fastifydepth/fastify/routes.tsdepth/fastify/routes.jspaths
// a named parameter: /licences/42 gives { id: '42' }
app.get('/licences/:id', async (request) => request.params);
// only the last parameter can be optional: /search and /search/fire
app.get(
  '/search/:term?',
  async (request) => request.params,
);
// a wildcard: /docs/a/b gives { '*': 'a/b' }
app.get('/docs/*', async (request) => request.params);
// a regular expression: anything that does not match is a 404
app.get(
  '/years/:year(^\\d{4}$)',
  async (request) => request.params,
);
// a named parameter: /licences/42 gives { id: '42' }
app.get('/licences/:id', async (request) => request.params);
// only the last parameter can be optional: /search and /search/fire
app.get(
  '/search/:term?',
  async (request) => request.params,
);
// a wildcard: /docs/a/b gives { '*': 'a/b' }
app.get('/docs/*', async (request) => request.params);
// a regular expression: anything that does not match is a 404
app.get(
  '/years/:year(^\\d{4}$)',
  async (request) => request.params,
);
PatternExpress 5Fastify 5Gives
named parameter/licences/:id/licences/:id{ id: '42' } for /licences/42
optional part/search{/:term}: any part, in braces/search/:term?: the last parameter only{} for /search, { term: 'fire' } for /search/fire
wildcard/docs/*path: it needs a name/docs/*for /docs/a/b, Express gives { path: ['a', 'b'] } and Fastify { '*': 'a/b' }
patternnot in the path: test the value in the handler/years/:year(^\\d{4}$)a 404 for /years/abc, in both

Several handlers on one route#

A route can run more than one function. Express takes extra handlers before the last one, and each calls next() to go on, or answers the request itself. Fastify puts them in the route's preHandler option, as hooks that run just before the handler. A hook that answers returns reply.

ASP.NET Core Like an endpoint filter that returns a result instead of calling next.
Expressdepth/express/routes.tsdepth/express/routes.jshandlers
// a handler that runs first, and may end the request itself
const onlyOpenLicences: RequestHandler = (
  req,
  res,
  next,
) => {
  if (req.params.id === '13') {
    return res.status(403).send('licence 13 is suspended');
  }
  next();
};
app.get(
  '/licences/:id/summary',
  onlyOpenLicences,
  (req, res) => {
    res.send(`summary of licence ${req.params.id}`);
  },
);
// a handler that runs first, and may end the request itself
const onlyOpenLicences = (req, res, next) => {
  if (req.params.id === '13') {
    return res.status(403).send('licence 13 is suspended');
  }
  next();
};
app.get(
  '/licences/:id/summary',
  onlyOpenLicences,
  (req, res) => {
    res.send(`summary of licence ${req.params.id}`);
  },
);
Fastifydepth/fastify/routes.tsdepth/fastify/routes.jshandlers
// a preHandler runs first, and may end the request itself
const onlyOpenLicences: preHandlerAsyncHookHandler = async (
  request,
  reply,
) => {
  const { id } = request.params as { id: string };
  if (id === '13') {
    return reply.code(403).send('licence 13 is suspended');
  }
};
app.get<WithId>(
  '/licences/:id/summary',
  { preHandler: [onlyOpenLicences] },
  async (request) =>
    `summary of licence ${request.params.id}`,
);
// a preHandler runs first, and may end the request itself
const onlyOpenLicences = async (request, reply) => {
  const { id } = request.params;
  if (id === '13') {
    return reply.code(403).send('licence 13 is suspended');
  }
};
app.get(
  '/licences/:id/summary',
  { preHandler: [onlyOpenLicences] },
  async (request) =>
    `summary of licence ${request.params.id}`,
);

Grouping routes under a prefix#

Services keep each group of pages in its own file. Express puts them on a router, and mounts it at a path with app.use(). Fastify puts them in a plugin, and registers it with a prefix. Either way, the routes inside give only the end of the path.

Grouping routes under a prefix: a router mounted with app.use() in Express, and a plugin registered with a prefix in Fastify, answering the same two addresses. Grouping routes under a prefix, in each framework. In Express, app.use('/licences/:id/holder', holder) mounts a router made with Router({ mergeParams: true }). Its routes GET / and GET /address answer /licences/7/holder and /licences/7/holder/address, and mergeParams lets them read req.params.id. In Fastify, app.register(holderRoutes, { prefix: '/licences/:id/holder' }) registers a plugin. Its routes GET / and GET /address answer the same two addresses, and the id in the prefix reaches them with no extra option. EXPRESS: A ROUTER, MOUNTED const app = express() app.use('/licences/:id/holder', holder) holder = Router({ mergeParams: true }) GET / /licences/7/holder GET /address /licences/7/holder/address mergeParams: the routes can read req.params.id FASTIFY: A PLUGIN, WITH A PREFIX const app = Fastify() app.register(holderRoutes, { prefix: '/licences/:id/holder' }) GET / /licences/7/holder GET /address /licences/7/holder/address the id in the prefix reaches every route in the plugin
Figure Grouping routes under a prefix: a router mounted with app.use() in Express, and a plugin registered with a prefix in Fastify, answering the same two addresses.#
As text

Grouping routes under a prefix, in each framework. In Express, app.use('/licences/:id/holder', holder) mounts a router made with Router({ mergeParams: true }). Its routes GET / and GET /address answer /licences/7/holder and /licences/7/holder/address, and mergeParams lets them read req.params.id. In Fastify, app.register(holderRoutes, { prefix: '/licences/:id/holder' }) registers a plugin. Its routes GET / and GET /address answer the same two addresses, and the id in the prefix reaches them with no extra option.

ASP.NET Core Like MapGroup, which gives a group of endpoints one route prefix.
Expressdepth/express/routes.tsdepth/express/routes.jsrouter
// routes grouped in a router and mounted at a prefix;
// mergeParams lets them read :id from the prefix
const holder = Router({ mergeParams: true });
holder.get(
  '/',
  (req: Request<{ id: string }>, res: Response) => {
    res.send(`holder of licence ${req.params.id}`);
  },
);
holder.get(
  '/address',
  (req: Request<{ id: string }>, res: Response) => {
    res.send(`address for licence ${req.params.id}`);
  },
);
app.use('/licences/:id/holder', holder);
// routes grouped in a router and mounted at a prefix;
// mergeParams lets them read :id from the prefix
const holder = Router({ mergeParams: true });
holder.get('/', (req, res) => {
  res.send(`holder of licence ${req.params.id}`);
});
holder.get('/address', (req, res) => {
  res.send(`address for licence ${req.params.id}`);
});
app.use('/licences/:id/holder', holder);
Fastifydepth/fastify/routes.tsdepth/fastify/routes.jsprefix
// routes grouped in a plugin and registered at a prefix;
// parameters in the prefix reach every route in it
const holderRoutes: FastifyPluginAsync = async (holder) => {
  holder.get<WithId>(
    '/',
    async (request) =>
      `holder of licence ${request.params.id}`,
  );
  holder.get<WithId>(
    '/address',
    async (request) =>
      `address for licence ${request.params.id}`,
  );
};
await app.register(holderRoutes, {
  prefix: '/licences/:id/holder',
});
// routes grouped in a plugin and registered at a prefix;
// parameters in the prefix reach every route in it
const holderRoutes = async (holder) => {
  holder.get(
    '/',
    async (request) =>
      `holder of licence ${request.params.id}`,
  );
  holder.get(
    '/address',
    async (request) =>
      `address for licence ${request.params.id}`,
  );
};
await app.register(holderRoutes, {
  prefix: '/licences/:id/holder',
});
Gotcha
A mounted Express router cannot see the prefix's parameters unless you ask. Without mergeParams: true, the router's routes get only their own parameters, so req.params.id is missing. Fastify passes a prefix's parameters to every route in the plugin.

Two Express-only tools Express only#

next('route') skips the rest of a route's handlers, and tries the next route that matches. router.param() runs before any route with that parameter in its path, once per request, so it is the place to check or load a record.

depth/express/routes.tsdepth/express/routes.jsnext-route
// next('route') skips this route's other handlers
// and tries the next route that matches
app.get(
  '/renew/:id',
  (req, res, next) => {
    if (req.params.id === 'new') return next('route');
    next();
  },
  (req, res) => {
    res.send(`renew licence ${req.params.id}`);
  },
);
app.get('/renew/:id', (req, res) => {
  res.send('start a new application instead');
});
// next('route') skips this route's other handlers
// and tries the next route that matches
app.get(
  '/renew/:id',
  (req, res, next) => {
    if (req.params.id === 'new') return next('route');
    next();
  },
  (req, res) => {
    res.send(`renew licence ${req.params.id}`);
  },
);
app.get('/renew/:id', (req, res) => {
  res.send('start a new application instead');
});
depth/express/routes.tsdepth/express/routes.jsparam
// router.param() runs before any route with :ref in its path
const references = Router();
references.param('ref', (req, res, next, ref: string) => {
  if (!/^JL-\d{4}$/.test(ref)) {
    return res.status(404).send('no such reference');
  }
  next();
});
references.get('/reference/:ref', (req, res) => {
  res.send(`found ${req.params.ref}`);
});
app.use(references);
// router.param() runs before any route with :ref in its path
const references = Router();
references.param('ref', (req, res, next, ref) => {
  if (!/^JL-\d{4}$/.test(ref)) {
    return res.status(404).send('no such reference');
  }
  next();
});
references.get('/reference/:ref', (req, res) => {
  res.send(`found ${req.params.ref}`);
});
app.use(references);

Fastify has neither. It allows one route for each method and path, so a check like this goes in a preHandler, as above, or in a schema for the parameters.

Reading the request#

Everything a route knows about a request is on one object: req in Express, request in Fastify. Bodies and cookies are parsed first. The client's real address needs one setting when the app sits behind a load balancer or another proxy.

ASP.NET Core Like ForwardedHeadersOptions with KnownProxies and KnownNetworks.
Expressdepth/express/request.tsdepth/express/request.jstrust-proxy
// a load balancer sits in front of the app: trust its X-Forwarded-*
// headers, but only when they come from its addresses
app.set('trust proxy', ['loopback', '10.0.0.0/8']);
// a load balancer sits in front of the app: trust its X-Forwarded-*
// headers, but only when they come from its addresses
app.set('trust proxy', ['loopback', '10.0.0.0/8']);
Fastifydepth/fastify/request.tsdepth/fastify/request.jstrust-proxy
// a load balancer sits in front of the app: trust its X-Forwarded-*
// headers, but only when they come from its addresses
const app = Fastify({
  trustProxy: ['loopback', '10.0.0.0/8'],
});
// a load balancer sits in front of the app: trust its X-Forwarded-*
// headers, but only when they come from its addresses
const app = Fastify({
  trustProxy: ['loopback', '10.0.0.0/8'],
});
Gotcha
Fastify ignores a hop count. Express code often says app.set('trust proxy', 1), meaning "trust one proxy". Fastify 5 treats a number as trusting no proxy, and its types reject one, because anyone who reaches the app directly could fake the headers. Name the proxy's addresses, in both frameworks.
ASP.NET Core Like the form and JSON model binders, and the request's cookie collection.
Expressdepth/express/request.tsdepth/express/request.jsparsers
app.use(express.urlencoded());
app.use(express.json());
app.use(cookieParser());
app.use(express.urlencoded());
app.use(express.json());
app.use(cookieParser());
Fastifydepth/fastify/request.tsdepth/fastify/request.jsparsers
// JSON bodies need no plugin; form bodies and cookies do
await app.register(fastifyFormbody);
await app.register(fastifyCookie);
// JSON bodies need no plugin; form bodies and cookies do
await app.register(fastifyFormbody);
await app.register(fastifyCookie);
ASP.NET Core Like HttpContext.Request, with its RouteValues, Query, Form, Headers and Cookies.
Expressdepth/express/request.tsdepth/express/request.jsinspect
app.post('/inspect/:page', (req, res) => {
  res.json({
    params: req.params,
    query: req.query,
    body: req.body,
    host: req.get('host'),
    cookies: req.cookies,
    ip: req.ip,
    protocol: req.protocol,
    hostname: req.hostname,
    route: req.route.path,
    url: req.originalUrl,
  });
});
app.post('/inspect/:page', (req, res) => {
  res.json({
    params: req.params,
    query: req.query,
    body: req.body,
    host: req.get('host'),
    cookies: req.cookies,
    ip: req.ip,
    protocol: req.protocol,
    hostname: req.hostname,
    route: req.route.path,
    url: req.originalUrl,
  });
});
Fastifydepth/fastify/request.tsdepth/fastify/request.jsinspect
app.post<{ Params: { page: string } }>(
  '/inspect/:page',
  async (request) => ({
    params: request.params,
    query: request.query,
    body: request.body,
    host: request.host,
    cookies: request.cookies,
    ip: request.ip,
    protocol: request.protocol,
    hostname: request.hostname,
    route: request.routeOptions.url,
    url: request.url,
  }),
);
app.post('/inspect/:page', async (request) => ({
  params: request.params,
  query: request.query,
  body: request.body,
  host: request.host,
  cookies: request.cookies,
  ip: request.ip,
  protocol: request.protocol,
  hostname: request.hostname,
  route: request.routeOptions.url,
  url: request.url,
}));

The test posts a form to /inspect/name?change=true&props=clubs&props=rings, with a cookie and the proxy's headers. Both frameworks answer with the same values:

You wantExpressFastifyThe test gets
route parametersreq.paramsrequest.params{ page: 'name' }
the query stringreq.queryrequest.query{ change: 'true', props: ['clubs', 'rings'] }
a form or JSON bodyreq.bodyrequest.body{ fullName: 'Ada Lovelace', props: ['clubs', 'rings'] }
a headerreq.get('host')request.headers.host, or request.host127.0.0.1 and the port
cookiesreq.cookies, with cookie-parserrequest.cookies, with @fastify/cookie{ theme: 'dark' }
the client's IP addressreq.iprequest.ip203.0.113.7, from X-Forwarded-For
the protocolreq.protocolrequest.protocolhttps, from X-Forwarded-Proto
the host namereq.hostnamerequest.hostname127.0.0.1
the route's patternreq.route.pathrequest.routeOptions.url/inspect/:page
the URLreq.originalUrlrequest.urlthe path and query string, as sent
the sessionreq.sessionrequest.sessionsee chapter 6
Gotcha
One ticked box arrives as a string, two as a list. A checkbox group posts one field for each ticked box. Both frameworks turn repeated fields into a list, ['clubs', 'rings'], but a single tick stays a string, 'clubs'. Wrap a single value in a list before you loop over it.

Sending the response#

An Express handler calls methods on res. A Fastify handler sets the status and headers on reply, then returns what to send. Each pair below does the same job.

ASP.NET Core Like Results.Created, with its location header.
Expressdepth/express/response.tsdepth/express/response.jsstatus-json
app.post('/api/licences', (req, res) => {
  res
    .status(201)
    .set('Location', '/api/licences/42')
    .json({ id: '42' });
});
app.post('/api/licences', (req, res) => {
  res
    .status(201)
    .set('Location', '/api/licences/42')
    .json({ id: '42' });
});
Fastifydepth/fastify/response.tsdepth/fastify/response.jsstatus-json
app.post('/api/licences', async (request, reply) => {
  reply.code(201).header('Location', '/api/licences/42');
  return { id: '42' };
});
app.post('/api/licences', async (request, reply) => {
  reply.code(201).header('Location', '/api/licences/42');
  return { id: '42' };
});
ASP.NET Core Like Results.Text with a content type.
Expressdepth/express/response.tsdepth/express/response.jstext
app.get('/robots.txt', (req, res) => {
  res.type('text/plain').send('User-agent: *\nDisallow: /');
});
app.get('/robots.txt', (req, res) => {
  res.type('text/plain').send('User-agent: *\nDisallow: /');
});
Fastifydepth/fastify/response.tsdepth/fastify/response.jstext
app.get('/robots.txt', async (request, reply) => {
  reply.type('text/plain');
  return 'User-agent: *\nDisallow: /';
});
app.get('/robots.txt', async (request, reply) => {
  reply.type('text/plain');
  return 'User-agent: *\nDisallow: /';
});
ASP.NET Core Like Results.Redirect and Results.RedirectPermanent.
Expressdepth/express/response.tsdepth/express/response.jsredirect
app.post('/continue', (req, res) => {
  res.redirect('/next'); // 302 Found
});
app.post('/moved', (req, res) => {
  res.redirect(301, '/new-address'); // the status comes first
});
app.post('/continue', (req, res) => {
  res.redirect('/next'); // 302 Found
});
app.post('/moved', (req, res) => {
  res.redirect(301, '/new-address'); // the status comes first
});
Fastifydepth/fastify/response.tsdepth/fastify/response.jsredirect
app.post(
  '/continue',
  async (request, reply) => reply.redirect('/next'), // 302 Found
);
app.post(
  '/moved',
  async (request, reply) =>
    reply.redirect('/new-address', 301), // the status comes second
);
app.post(
  '/continue',
  async (request, reply) => reply.redirect('/next'), // 302 Found
);
app.post(
  '/moved',
  async (request, reply) =>
    reply.redirect('/new-address', 301), // the status comes second
);
ASP.NET Core Like Response.Cookies.Append with CookieOptions.
Expressdepth/express/response.tsdepth/express/response.jscookie
// maxAge is in milliseconds; the path defaults to /
app.post('/cookies', (req, res) => {
  res.cookie('analytics', 'rejected', {
    httpOnly: true,
    sameSite: 'lax',
    maxAge: 365 * 24 * 60 * 60 * 1000,
  });
  res.redirect('/cookies');
});
// maxAge is in milliseconds; the path defaults to /
app.post('/cookies', (req, res) => {
  res.cookie('analytics', 'rejected', {
    httpOnly: true,
    sameSite: 'lax',
    maxAge: 365 * 24 * 60 * 60 * 1000,
  });
  res.redirect('/cookies');
});
Fastifydepth/fastify/response.tsdepth/fastify/response.jscookie
// maxAge is in seconds; with no path, the browser uses the page's folder
app.post('/cookies', async (request, reply) => {
  reply.setCookie('analytics', 'rejected', {
    httpOnly: true,
    sameSite: 'lax',
    maxAge: 365 * 24 * 60 * 60,
    path: '/',
  });
  return reply.redirect('/cookies');
});
// maxAge is in seconds; with no path, the browser uses the page's folder
app.post('/cookies', async (request, reply) => {
  reply.setCookie('analytics', 'rejected', {
    httpOnly: true,
    sameSite: 'lax',
    maxAge: 365 * 24 * 60 * 60,
    path: '/',
  });
  return reply.redirect('/cookies');
});
ASP.NET Core Like ViewData, filled for every view by a filter.
Expressdepth/express/response.tsdepth/express/response.jslocals
// app.locals reaches every template; res.locals reaches
// the templates rendered for this one request
app.locals.serviceName = 'Apply for a juggling licence';
app.use((req, res, next) => {
  res.locals.phase = 'beta';
  next();
});
app.get('/about', (req, res) => {
  res.render('about', { title: 'About this service' });
});
// app.locals reaches every template; res.locals reaches
// the templates rendered for this one request
app.locals.serviceName = 'Apply for a juggling licence';
app.use((req, res, next) => {
  res.locals.phase = 'beta';
  next();
});
app.get('/about', (req, res) => {
  res.render('about', { title: 'About this service' });
});
Fastifydepth/fastify/response.tsdepth/fastify/response.jslocals
// defaultContext reaches every template; reply.locals reaches
// the templates rendered for this one request
await app.register(fastifyView, {
  engine: { nunjucks },
  templates: join(import.meta.dirname, '..', 'views'),
  viewExt: 'njk',
  defaultContext: {
    serviceName: 'Apply for a juggling licence',
  },
});
app.addHook('onRequest', async (request, reply) => {
  reply.locals = { phase: 'beta' };
});
app.get('/about', async (request, reply) =>
  reply.view('about', { title: 'About this service' }),
);
// defaultContext reaches every template; reply.locals reaches
// the templates rendered for this one request
await app.register(fastifyView, {
  engine: { nunjucks },
  templates: join(import.meta.dirname, '..', 'views'),
  viewExt: 'njk',
  defaultContext: {
    serviceName: 'Apply for a juggling licence',
  },
});
app.addHook('onRequest', async (request, reply) => {
  reply.locals = { phase: 'beta' };
});
app.get('/about', async (request, reply) =>
  reply.view('about', { title: 'About this service' }),
);
ASP.NET Core Like middleware that writes the response and does not call next.
Expressdepth/express/response.tsdepth/express/response.jsearly
// middleware that answers the request itself,
// and so never calls next()
app.use('/admin', (req, res, next) => {
  if (req.get('x-staff') !== 'yes') {
    return res.status(403).send('staff only');
  }
  next();
});
app.get('/admin', (req, res) => {
  res.send('admin home');
});
// middleware that answers the request itself,
// and so never calls next()
app.use('/admin', (req, res, next) => {
  if (req.get('x-staff') !== 'yes') {
    return res.status(403).send('staff only');
  }
  next();
});
app.get('/admin', (req, res) => {
  res.send('admin home');
});
Fastifydepth/fastify/response.tsdepth/fastify/response.jsearly
// a hook that answers the request itself, so the route never runs
await app.register(
  async (admin) => {
    admin.addHook('onRequest', async (request, reply) => {
      if (request.headers['x-staff'] !== 'yes') {
        return reply.code(403).send('staff only');
      }
    });
    admin.get('/', async () => 'admin home');
  },
  { prefix: '/admin' },
);
// a hook that answers the request itself, so the route never runs
await app.register(
  async (admin) => {
    admin.addHook('onRequest', async (request, reply) => {
      if (request.headers['x-staff'] !== 'yes') {
        return reply.code(403).send('staff only');
      }
    });
    admin.get('/', async () => 'admin home');
  },
  { prefix: '/admin' },
);
JobExpressFastify
status coderes.status(201)reply.code(201)
a headerres.set('Location', url)reply.header('Location', url)
content typeres.type('text/plain')reply.type('text/plain')
a stringres.send(text): HTML unless you set a typereturn text: plain text unless you set a type
JSONres.json(object)return object
a templateres.render(view, data)reply.view(view, data), with @fastify/view
redirectres.redirect(url), or res.redirect(301, url)reply.redirect(url), or reply.redirect(url, 301)
a cookieres.cookie(name, value, options)reply.setCookie(name, value, options)
data for every templateapp.localsdefaultContext, or a Nunjucks global
data for this request's templatesres.localsreply.locals
answer earlysend, and do not call next()reply.send(), then return reply
Gotcha
A redirect's status goes first in Express and second in Fastify. Compare res.redirect(301, url) with reply.redirect(url, 301). Express 4 code may say res.redirect(url, 301), which Express 5 no longer accepts.
Gotcha
A cookie's maxAge is in milliseconds in Express, and in seconds in Fastify. Both examples set a one-year cookie, and both send Max-Age=31536000. Express also sets the path to /. Fastify sends no path, so the browser keeps the cookie for the page's folder only. Set path: '/' on every Fastify cookie.
Gotcha
Send once. In Express, a second res.send() throws "Cannot set headers after they are sent to the client". In an async Fastify handler, either return the value, or call reply.send() and return reply. If you do both, Fastify keeps the first and logs a warning.

Schemas Fastify only#

A Fastify route can carry a JSON Schema for its body, query string and parameters, and one for each response status. Fastify checks the request against them with Ajv before your handler runs. It then writes the reply from the response schema.

What Fastify does with a route's schemas: check and convert the request, run your handler, then write the reply from the response schemanoyesthe body is parsed from JSON or a formdoes it match schema.body? Ajv alsoconverts types, fills in defaults andremoves unlisted fields400 FST_ERR_VALIDATION, unless theroute sets attachValidationpreHandler hooks, then your handler;with attachValidation,request.validationError holds thefailurewhat the handler returns is writtenwith schema.response, which dropsfields it does not listthe response is sent
Figure What Fastify does with a route's schemas: check and convert the request, run your handler, then write the reply from the response schema#
As text
  1. the body is parsed from JSON or a form
  2. does it match schema.body? Ajv also converts types, fills in defaults and removes unlisted fields No: 400 FST_ERR_VALIDATION, unless the route sets attachValidation. Yes: the next step.
  3. preHandler hooks, then your handler; with attachValidation, request.validationError holds the failure
  4. what the handler returns is written with schema.response, which drops fields it does not list
  5. the response is sent
depth/fastify/schema.tsdepth/fastify/schema.jsschema
app.post(
  '/api/licences',
  {
    schema: {
      body: {
        type: 'object',
        required: ['fullName', 'props'],
        properties: {
          fullName: { type: 'string', minLength: 1 },
          props: {
            type: 'array',
            items: { enum: ['clubs', 'rings', 'knives'] },
          },
          years: {
            type: 'integer',
            minimum: 0,
            default: 0,
          },
        },
        additionalProperties: false,
      },
      response: {
        201: {
          type: 'object',
          properties: {
            id: { type: 'string' },
            fullName: { type: 'string' },
            props: {
              type: 'array',
              items: { type: 'string' },
            },
            years: { type: 'integer' },
          },
        },
      },
    },
  },
  async (request, reply) => {
    reply.code(201);
    const body = request.body as Record<string, unknown>;
    return { id: 'JL-0042', ...body, note: 'never sent' };
  },
);
app.post(
  '/api/licences',
  {
    schema: {
      body: {
        type: 'object',
        required: ['fullName', 'props'],
        properties: {
          fullName: { type: 'string', minLength: 1 },
          props: {
            type: 'array',
            items: { enum: ['clubs', 'rings', 'knives'] },
          },
          years: {
            type: 'integer',
            minimum: 0,
            default: 0,
          },
        },
        additionalProperties: false,
      },
      response: {
        201: {
          type: 'object',
          properties: {
            id: { type: 'string' },
            fullName: { type: 'string' },
            props: {
              type: 'array',
              items: { type: 'string' },
            },
            years: { type: 'integer' },
          },
        },
      },
    },
  },
  async (request, reply) => {
    reply.code(201);
    const body = request.body;
    return { id: 'JL-0042', ...body, note: 'never sent' };
  },
);

Fastify turns on three Ajv settings that change the data as it checks it:

  • coerceTypes: '3' becomes 3 where the schema says integer. Every form field arrives as text, so this matters.
  • useDefaults: a missing years becomes 0.
  • removeAdditional: with additionalProperties: false, fields the schema does not list are removed, not refused.
The test sendsFastify answers
{ fullName: 'Ada', props: ['clubs'], years: '3', admin: true }201 and { id: 'JL-0042', fullName: 'Ada', props: ['clubs'], years: 3 }: admin removed, years converted, and the handler's note dropped by the response schema
{ fullName: 'Ada', props: ['clubs'] }201 with years: 0, the default
{ props: ['clubs'] }400: body must have required property 'fullName'
{ fullName: 'Ada', props: ['fire'] }400: body/props/0 must be equal to one of the allowed values

The 400 is JSON, with statusCode, error, message, and code set to FST_ERR_VALIDATION. That suits an API, not a GOV.UK form. The query string works the same way, and a bad value such as ?page=abc gets "querystring/page must be integer":

depth/fastify/schema.tsdepth/fastify/schema.jsquery
// the query string is converted to the schema's types,
// and a default fills in a missing value
app.get<{ Querystring: { page: number } }>(
  '/api/licences',
  {
    schema: {
      querystring: {
        type: 'object',
        properties: {
          page: { type: 'integer', minimum: 1, default: 1 },
        },
      },
    },
  },
  async (request) => ({ page: request.query.page }),
);
// the query string is converted to the schema's types,
// and a default fills in a missing value
app.get(
  '/api/licences',
  {
    schema: {
      querystring: {
        type: 'object',
        properties: {
          page: { type: 'integer', minimum: 1, default: 1 },
        },
      },
    },
  },
  async (request) => ({ page: request.query.page }),
);

For a form, set attachValidation: true. Fastify then keeps the failure on request.validationError and runs your handler, which can show the page again with GOV.UK error messages:

depth/fastify/schema.tsdepth/fastify/schema.jsattach-validation
// a form post: keep the validation error, and show the page
// again with GOV.UK error messages instead of a 400
const messages: Record<string, string> = {
  fullName: 'Enter your full name',
};
const fieldOf = (e: FastifySchemaValidationError) =>
  e.keyword === 'required'
    ? String(e.params.missingProperty)
    : e.instancePath.slice(1);
app.post(
  '/name',
  {
    attachValidation: true,
    schema: {
      body: {
        type: 'object',
        required: ['fullName'],
        properties: {
          fullName: { type: 'string', minLength: 1 },
        },
      },
    },
  },
  async (request, reply) => {
    if (request.validationError) {
      const errors = request.validationError.validation.map(
        (e: FastifySchemaValidationError) => ({
          field: fieldOf(e),
          text: messages[fieldOf(e)],
        }),
      );
      // a real page renders its template with these
      return errorView(errors);
    }
    return reply.redirect('/email');
  },
);
// a form post: keep the validation error, and show the page
// again with GOV.UK error messages instead of a 400
const messages = {
  fullName: 'Enter your full name',
};
const fieldOf = (e) =>
  e.keyword === 'required'
    ? String(e.params.missingProperty)
    : e.instancePath.slice(1);
app.post(
  '/name',
  {
    attachValidation: true,
    schema: {
      body: {
        type: 'object',
        required: ['fullName'],
        properties: {
          fullName: { type: 'string', minLength: 1 },
        },
      },
    },
  },
  async (request, reply) => {
    if (request.validationError) {
      const errors = request.validationError.validation.map(
        (e) => ({
          field: fieldOf(e),
          text: messages[fieldOf(e)],
        }),
      );
      // a real page renders its template with these
      return errorView(errors);
    }
    return reply.redirect('/email');
  },
);
Note
Express has no schemas. Express services check form values in the handler, as the service does in chapter 11. Fastify services can too.

Read it: a renewals plugin#

Here is a Fastify plugin written the way a team might write one, for renewing a licence. The service registers it with app.register(renewalRoutes, { prefix: '/renew' }). Read the code first, then the notes.

depth/fastify/renewals.tsdepth/fastify/renewals.jsplugin
type Renewal = {
  Params: { ref: string };
  Body: { years: number };
};

const loadLicence: preHandlerAsyncHookHandler = async (
  request,
  reply,
) => {
  const { ref } = request.params as Renewal['Params'];
  const licence = await findLicence(ref);
  if (!licence) {
    reply.callNotFound();
    return reply;
  }
  reply.locals = { ...reply.locals, licence };
};

export const renewalRoutes: FastifyPluginAsync = async (
  app,
) => {
  app.get<Renewal>(
    '/:ref',
    { preHandler: loadLicence },
    async (request, reply) => reply.view('renew', {}),
  );

  app.post<Renewal>(
    '/:ref',
    {
      preHandler: loadLicence,
      attachValidation: true,
      schema: {
        body: {
          type: 'object',
          required: ['years'],
          properties: {
            years: { type: 'integer', enum: [1, 3, 5] },
          },
        },
      },
    },
    async (request, reply) => {
      if (request.validationError) {
        return reply.view('renew', {
          error: 'Select how many years to renew for',
        });
      }
      request.log.info(
        {
          ref: request.params.ref,
          years: request.body.years,
        },
        'licence renewed',
      );
      return reply.redirect(
        `/renew/${request.params.ref}/done`,
        303,
      );
    },
  );
};
const loadLicence = async (request, reply) => {
  const { ref } = request.params;
  const licence = await findLicence(ref);
  if (!licence) {
    reply.callNotFound();
    return reply;
  }
  reply.locals = { ...reply.locals, licence };
};

export const renewalRoutes = async (app) => {
  app.get(
    '/:ref',
    { preHandler: loadLicence },
    async (request, reply) => reply.view('renew', {}),
  );

  app.post(
    '/:ref',
    {
      preHandler: loadLicence,
      attachValidation: true,
      schema: {
        body: {
          type: 'object',
          required: ['years'],
          properties: {
            years: { type: 'integer', enum: [1, 3, 5] },
          },
        },
      },
    },
    async (request, reply) => {
      if (request.validationError) {
        return reply.view('renew', {
          error: 'Select how many years to renew for',
        });
      }
      request.log.info(
        {
          ref: request.params.ref,
          years: request.body.years,
        },
        'licence renewed',
      );
      return reply.redirect(
        `/renew/${request.params.ref}/done`,
        303,
      );
    },
  );
};
  1. The routes' types in one place. With app.get<Renewal> and app.post<Renewal>, request.params.ref is a string and request.body.years a number.
  2. A preHandler that both routes share. It reads the reference from the path. A hook written on its own does not know the route's types, so TypeScript needs the cast.
  3. No such licence: hand the request to the not-found handler, then return reply so that Fastify knows the hook has answered.
  4. Found: put the licence where every template this request renders can see it, keeping anything already there.
  5. Route options sit between the path and the handler. This preHandler runs after validation and before the handler.
  6. The page reads the licence from reply.locals, so the handler passes nothing more.
  7. Keep a validation failure for the handler, instead of answering with a 400.
  8. The form posts years as text. Ajv converts it to a number, then checks it is 1, 3 or 5.
  9. The body failed its schema: show the page again, with a message a person can act on.
  10. Fastify's built-in logger, which adds the request's id to each line. Chapter 5 covers logging.
  11. The status comes after the URL. 303 See Other tells the browser to GET the next page.

Write it: a date of birth page#

The task: add a page that asks for a date of birth, checks it the way the Design System says, and saves it. The finished code runs in both services. Its test tries an empty date, a missing month, 31 February, a future date and a real one.

1. The template. The date input macro draws three fields: dob-day, dob-month and dob-year. A field gets the error class only when the rule highlights it.

views/pages/date-of-birth.njkfield
{% set bad = dobError.highlight if dobError else [] %}
{{ govukDateInput({
  id: "dob",
  namePrefix: "dob",
  fieldset: {
    legend: {
      text: title,
      isPageHeading: true,
      classes: "govuk-fieldset__legend--l"
    }
  },
  hint: { text: "For example, 27 3 2007" },
  errorMessage: { text: dobError.text } if dobError else false,
  items: [
    {
      name: "day",
      value: values.day,
      classes: "govuk-input--width-2" ~ (" govuk-input--error" if "day" in bad)
    },
    {
      name: "month",
      value: values.month,
      classes: "govuk-input--width-2" ~ (" govuk-input--error" if "month" in bad)
    },
    {
      name: "year",
      value: values.year,
      classes: "govuk-input--width-4" ~ (" govuk-input--error" if "year" in bad)
    }
  ]
}) }}

2. The rule. It gives one message at a time, in the Design System's order: nothing entered, a missing part, a year without four digits, a date that cannot exist, then a date in the future. JavaScript's Date quietly turns 31 February into a day in March, so the rule builds the date and checks that the day, month and year survived.

depth/shared/date-of-birth.tsdepth/shared/date-of-birth.jsrule
// one message at a time, most important first, with the parts to highlight
export function validateDateOfBirth(
  parts: DateParts,
  today = new Date(),
): DateError | null {
  const missing = PARTS.filter((p) => parts[p] === '');
  if (missing.length === PARTS.length) {
    return {
      text: 'Enter your date of birth',
      highlight: PARTS,
    };
  }
  if (missing.length > 0) {
    return {
      text: `Date of birth must include a ${missing.join(' and ')}`,
      highlight: missing,
    };
  }
  if (!/^\d{4}$/.test(parts.year)) {
    return {
      text: 'Year must include 4 numbers',
      highlight: ['year'],
    };
  }
  const [day, month, year] = PARTS.map((p) =>
    Number(parts[p]),
  );
  const date = new Date(Date.UTC(year, month - 1, day));
  const real =
    /^\d{1,2}$/.test(parts.day) &&
    /^\d{1,2}$/.test(parts.month) &&
    date.getUTCFullYear() === year &&
    date.getUTCMonth() === month - 1 &&
    date.getUTCDate() === day;
  if (!real) {
    return {
      text: 'Date of birth must be a real date',
      highlight: PARTS,
    };
  }
  if (date > today) {
    return {
      text: 'Date of birth must be in the past',
      highlight: PARTS,
    };
  }
  return null;
}
// one message at a time, most important first, with the parts to highlight
export function validateDateOfBirth(
  parts,
  today = new Date(),
) {
  const missing = PARTS.filter((p) => parts[p] === '');
  if (missing.length === PARTS.length) {
    return {
      text: 'Enter your date of birth',
      highlight: PARTS,
    };
  }
  if (missing.length > 0) {
    return {
      text: `Date of birth must include a ${missing.join(' and ')}`,
      highlight: missing,
    };
  }
  if (!/^\d{4}$/.test(parts.year)) {
    return {
      text: 'Year must include 4 numbers',
      highlight: ['year'],
    };
  }
  const [day, month, year] = PARTS.map((p) =>
    Number(parts[p]),
  );
  const date = new Date(Date.UTC(year, month - 1, day));
  const real =
    /^\d{1,2}$/.test(parts.day) &&
    /^\d{1,2}$/.test(parts.month) &&
    date.getUTCFullYear() === year &&
    date.getUTCMonth() === month - 1 &&
    date.getUTCDate() === day;
  if (!real) {
    return {
      text: 'Date of birth must be a real date',
      highlight: PARTS,
    };
  }
  if (date > today) {
    return {
      text: 'Date of birth must be in the past',
      highlight: PARTS,
    };
  }
  return null;
}

The error summary links to the first field to fix:

depth/shared/date-of-birth.tsdepth/shared/date-of-birth.jsview
// what the template needs: the error summary links to the first highlighted part
export function dateErrorView(error: DateError) {
  return {
    ...errorView([
      {
        field: `dob-${error.highlight[0]}`,
        text: error.text,
      },
    ]),
    dobError: error,
  };
}
// what the template needs: the error summary links to the first highlighted part
export function dateErrorView(error) {
  return {
    ...errorView([
      {
        field: `dob-${error.highlight[0]}`,
        text: error.text,
      },
    ]),
    dobError: error,
  };
}

3. The routes. A GET shows the page with any saved answer. A POST checks the answer, then either shows the page again or saves it and moves on.

ASP.NET Core Like a Razor Page with an OnGet and an OnPost.
Expressdepth/express/date-of-birth.tsdepth/express/date-of-birth.jsroutes
export function dateOfBirthRoutes() {
  const router = Router();

  router.get('/date-of-birth', (req, res) => {
    res.render('pages/date-of-birth', {
      values: req.session.dateOfBirth ?? {},
      backLink: '/name',
    });
  });

  router.post('/date-of-birth', (req, res) => {
    const values = dateParts(req.body);
    const error = validateDateOfBirth(values);
    if (error) {
      return res.render('pages/date-of-birth', {
        values,
        ...dateErrorView(error),
        backLink: '/name',
      });
    }
    req.session.dateOfBirth = values;
    res.redirect('/check-answers');
  });

  return router;
}
export function dateOfBirthRoutes() {
  const router = Router();

  router.get('/date-of-birth', (req, res) => {
    res.render('pages/date-of-birth', {
      values: req.session.dateOfBirth ?? {},
      backLink: '/name',
    });
  });

  router.post('/date-of-birth', (req, res) => {
    const values = dateParts(req.body);
    const error = validateDateOfBirth(values);
    if (error) {
      return res.render('pages/date-of-birth', {
        values,
        ...dateErrorView(error),
        backLink: '/name',
      });
    }
    req.session.dateOfBirth = values;
    res.redirect('/check-answers');
  });

  return router;
}
Fastifydepth/fastify/date-of-birth.tsdepth/fastify/date-of-birth.jsroutes
export const dateOfBirthRoutes: FastifyPluginAsync = async (
  app,
) => {
  app.get('/date-of-birth', async (request, reply) =>
    reply.view('pages/date-of-birth', {
      values: request.session.dateOfBirth ?? {},
      backLink: '/name',
    }),
  );

  app.post('/date-of-birth', async (request, reply) => {
    const values = dateParts(
      request.body as Record<string, unknown>,
    );
    const error = validateDateOfBirth(values);
    if (error) {
      return reply.view('pages/date-of-birth', {
        values,
        ...dateErrorView(error),
        backLink: '/name',
      });
    }
    request.session.dateOfBirth = values;
    return reply.redirect('/check-answers');
  });
};
export const dateOfBirthRoutes = async (app) => {
  app.get('/date-of-birth', async (request, reply) =>
    reply.view('pages/date-of-birth', {
      values: request.session.dateOfBirth ?? {},
      backLink: '/name',
    }),
  );

  app.post('/date-of-birth', async (request, reply) => {
    const values = dateParts(request.body);
    const error = validateDateOfBirth(values);
    if (error) {
      return reply.view('pages/date-of-birth', {
        values,
        ...dateErrorView(error),
        backLink: '/name',
      });
    }
    request.session.dateOfBirth = values;
    return reply.redirect('/check-answers');
  });
};
TypeScript only

Keeping the date in the session means adding it to the session's type:

depth/express/date-of-birth.tssession-type
declare module 'express-session' {
  interface SessionData {
    dateOfBirth: DateParts;
  }
}
depth/fastify/date-of-birth.tssession-type
declare module 'fastify' {
  interface Session {
    dateOfBirth?: DateParts;
  }
}

4. Add it to the service. Express must add the router before its error pages, so the service's createApp() takes extra routers. Fastify registers one more plugin.

ASP.NET Core Like adding one more Razor Page to an app that already runs.
Expressdepth/express/date-of-birth.tsdepth/express/date-of-birth.jsmount
// the service, with the new page added before its error pages
export function createAppWithDateOfBirth() {
  return createApp([dateOfBirthRoutes()]);
}
// the service, with the new page added before its error pages
export function createAppWithDateOfBirth() {
  return createApp([dateOfBirthRoutes()]);
}
Fastifydepth/fastify/date-of-birth.tsdepth/fastify/date-of-birth.jsmount
// the service, with the new page registered as one more plugin
export async function buildAppWithDateOfBirth() {
  const app = await buildApp();
  await app.register(dateOfBirthRoutes);
  return app;
}
// the service, with the new page registered as one more plugin
export async function buildAppWithDateOfBirth() {
  const app = await buildApp();
  await app.register(dateOfBirthRoutes);
  return app;
}
On the job#
Find it
List the routes: search for Router(, app.use( and .get( in Express, and app.register( and prefix in Fastify. Each mount path or prefix is added to the start of every route inside it.
Add it
Give each group of pages its own router or plugin at one prefix. Trust the proxy by its addresses, and set path: '/' on every Fastify cookie.

Structure, errors, logging and tests#

Part II · The server · Chapter 05·8 min read

How Express and Fastify apps are laid out, how errors and logs flow, and how to test them. It ends with the Express 4 and Fastify 4 code you will meet in older services.

How an app is laid out#

Services split into the same files in both frameworks. This book's service is laid out like this:

FileWhat it holdsIn this book
appbuilds the app: settings, middleware or plugins, routes and error pages; it never listensexpress/app.ts, fastify/app.ts
serverreads the configuration, calls the app's function and listens on a portexpress/server.ts, fastify/server.ts
routesthe routes, grouped in a router or a pluginexpress/routes.ts, fastify/routes.ts
viewsthe Nunjucks templatesviews/
shared coderules that do not depend on the framework, such as validationshared/
teststests that build the app and send it requeststest/

Keeping the app apart from the server lets a test build the app without listening on a port. Configuration comes from environment variables, read once at start-up. A missing secret should stop the service starting, not fail its first request:

depth/shared/config.tsdepth/shared/config.jsconfig
export function loadConfig(
  env: NodeJS.ProcessEnv = process.env,
) {
  const production = env.NODE_ENV === 'production';
  const sessionSecret =
    env.SESSION_SECRET ??
    (production
      ? undefined
      : 'development-only-secret-of-at-least-32-characters');
  // fail at start-up, not on the first request
  if (!sessionSecret || sessionSecret.length < 32) {
    throw new Error(
      'SESSION_SECRET must be set, and at least 32 characters',
    );
  }
  return {
    production,
    port: Number(env.PORT ?? 3000),
    sessionSecret,
    licensingApi:
      env.LICENSING_API_URL ?? 'http://localhost:4000',
  };
}
export function loadConfig(env = process.env) {
  const production = env.NODE_ENV === 'production';
  const sessionSecret =
    env.SESSION_SECRET ??
    (production
      ? undefined
      : 'development-only-secret-of-at-least-32-characters');
  // fail at start-up, not on the first request
  if (!sessionSecret || sessionSecret.length < 32) {
    throw new Error(
      'SESSION_SECRET must be set, and at least 32 characters',
    );
  }
  return {
    production,
    port: Number(env.PORT ?? 3000),
    sessionSecret,
    licensingApi:
      env.LICENSING_API_URL ?? 'http://localhost:4000',
  };
}

Errors#

In both frameworks, an error thrown in a handler, or a promise that rejects, goes to the error handler. An error can carry the status to answer with: Express reads err.status or err.statusCode, and Fastify reads statusCode.

Where a thrown error goes: to the first error handler in Express, and to the nearest setErrorHandler in Fastify, with each framework's own default behind it. Where a thrown error goes, in each framework. In Express, a handler that throws, returns a rejected promise, or calls next(err) makes Express skip every normal middleware and route. The error goes to the first error handler, a function with four parameters, err, req, res and next. If there is none, Express's own handler answers, with the stack trace, or only the status text when NODE_ENV is production. In Fastify, a handler or hook that throws or rejects goes to the nearest setErrorHandler, looking outwards from the route's plugin. If there is none, Fastify's default answers with JSON holding statusCode, error and message. A request that matches no route goes to the not-found handler, not the error handler, in both frameworks. EXPRESS a handler throws, its promise rejects, or it calls next(err) every normal middleware and route is skipped (err, req, res, next) => the first error handler, in the order added none Express's own handler: the stack trace, or the status text when NODE_ENV=production FASTIFY a handler or hook throws, or its promise rejects setErrorHandler the nearest, from the route's plugin outwards none Fastify's default: JSON with statusCode, error and message In both, a request that matches no route goes to the not-found handler, not the error handler.
Figure Where a thrown error goes: to the first error handler in Express, and to the nearest setErrorHandler in Fastify, with each framework's own default behind it.#
As text

Where a thrown error goes, in each framework. In Express, a handler that throws, returns a rejected promise, or calls next(err) makes Express skip every normal middleware and route. The error goes to the first error handler, a function with four parameters, err, req, res and next. If there is none, Express's own handler answers, with the stack trace, or only the status text when NODE_ENV is production. In Fastify, a handler or hook that throws or rejects goes to the nearest setErrorHandler, looking outwards from the route's plugin. If there is none, Fastify's default answers with JSON holding statusCode, error and message. A request that matches no route goes to the not-found handler, not the error handler, in both frameworks.

ASP.NET Core Like an exception that UseExceptionHandler catches.
Expressdepth/express/errors.tsdepth/express/errors.jsthrowing
// a throw and a rejected promise both reach the error handler
app.get('/sync', () => {
  throw new Error('something broke');
});
app.get('/async', async (req, res) => {
  const licence = await findLicence('JL-0042');
  res.send(licence.ref);
});
// an error can carry the status to answer with
app.post('/renew', () => {
  throw Object.assign(
    new Error('This licence has already been renewed'),
    { status: 409 },
  );
});
// a throw and a rejected promise both reach the error handler
app.get('/sync', () => {
  throw new Error('something broke');
});
app.get('/async', async (req, res) => {
  const licence = await findLicence('JL-0042');
  res.send(licence.ref);
});
// an error can carry the status to answer with
app.post('/renew', () => {
  throw Object.assign(
    new Error('This licence has already been renewed'),
    { status: 409 },
  );
});
Fastifydepth/fastify/errors.tsdepth/fastify/errors.jsthrowing
// a throw and a rejected promise both reach the error handler
app.get('/sync', () => {
  throw new Error('something broke');
});
app.get('/async', async () => {
  const licence = await findLicence('JL-0042');
  return licence.ref;
});
// an error can carry the status to answer with
app.post('/renew', async () => {
  throw Object.assign(
    new Error('This licence has already been renewed'),
    { statusCode: 409 },
  );
});
// a throw and a rejected promise both reach the error handler
app.get('/sync', () => {
  throw new Error('something broke');
});
app.get('/async', async () => {
  const licence = await findLicence('JL-0042');
  return licence.ref;
});
// an error can carry the status to answer with
app.post('/renew', async () => {
  throw Object.assign(
    new Error('This licence has already been renewed'),
    { statusCode: 409 },
  );
});

A request that matches no route is not an error. It goes to the not-found handler: a last middleware in Express, and setNotFoundHandler in Fastify. Express's error handler is middleware with four parameters, added after everything else.

ASP.NET Core Like UseStatusCodePages for 404s, and UseExceptionHandler for errors.
Expressdepth/express/errors.tsdepth/express/errors.jshandlers
// not found: the last middleware, reached by any request
// that nothing else answered
app.use((req, res) => {
  res
    .status(404)
    .render('problem', { title: 'Page not found' });
});
// errors: four parameters, registered after everything else
const onError: ErrorRequestHandler = (
  err,
  req,
  res,
  next,
) => {
  if (res.headersSent) return next(err);
  const status = err.status ?? 500;
  res.status(status).render('problem', {
    title:
      status === 500
        ? 'Sorry, there is a problem with the service'
        : err.message,
  });
};
app.use(onError);
// not found: the last middleware, reached by any request
// that nothing else answered
app.use((req, res) => {
  res
    .status(404)
    .render('problem', { title: 'Page not found' });
});
// errors: four parameters, registered after everything else
const onError = (err, req, res, next) => {
  if (res.headersSent) return next(err);
  const status = err.status ?? 500;
  res.status(status).render('problem', {
    title:
      status === 500
        ? 'Sorry, there is a problem with the service'
        : err.message,
  });
};
app.use(onError);
Fastifydepth/fastify/errors.tsdepth/fastify/errors.jshandlers
// not found: a handler of its own, not the error handler
app.setNotFoundHandler((request, reply) =>
  reply
    .code(404)
    .view('problem', { title: 'Page not found' }),
);
app.setErrorHandler(
  (
    error: { statusCode?: number; message: string },
    request,
    reply,
  ) => {
    const status = error.statusCode ?? 500;
    return reply.code(status).view('problem', {
      title:
        status === 500
          ? 'Sorry, there is a problem with the service'
          : error.message,
    });
  },
);
// not found: a handler of its own, not the error handler
app.setNotFoundHandler((request, reply) =>
  reply
    .code(404)
    .view('problem', { title: 'Page not found' }),
);
app.setErrorHandler((error, request, reply) => {
  const status = error.statusCode ?? 500;
  return reply.code(status).view('problem', {
    title:
      status === 500
        ? 'Sorry, there is a problem with the service'
        : error.message,
  });
});

Fastify's error handlers are scoped. One set inside a plugin covers only that plugin's routes, which suits an API that answers errors with JSON:

depth/fastify/errors.tsdepth/fastify/errors.jsscoped
// an error handler set inside a plugin covers only that plugin's routes
await app.register(
  async (api) => {
    api.setErrorHandler(async (error, request, reply) =>
      reply
        .code(502)
        .send({ error: 'The licensing API failed' }),
    );
    api.get('/licences/:ref', async () =>
      findLicence('JL-0042'),
    );
  },
  { prefix: '/api' },
);
// an error handler set inside a plugin covers only that plugin's routes
await app.register(
  async (api) => {
    api.setErrorHandler(async (error, request, reply) =>
      reply
        .code(502)
        .send({ error: 'The licensing API failed' }),
    );
    api.get('/licences/:ref', async () =>
      findLicence('JL-0042'),
    );
  },
  { prefix: '/api' },
);
The test requestsBoth frameworks answer
GET /sync500 and "Sorry, there is a problem with the service"
GET /async500 and the same page: the rejected promise reached the handler
POST /renew409 and "This licence has already been renewed", from the error's status
GET /nowhere404 and "Page not found", from the not-found handler
GET /api/licences/JL-0042in Fastify, 502 and JSON from the plugin's own handler
Gotcha
The default error handlers show too much. Express's default answers with the stack trace unless NODE_ENV is production. Fastify's default sends the error's message as it is, which can include a database's error text. Always set your own handler that shows a GOV.UK error page.

Logging#

Both frameworks log through pino, which writes one JSON object per line. Fastify builds it in, but it is off until you turn it on. Express needs pino-http. Both give every request an id, and add it to every line that request logs.

ASP.NET Core Like the request logging middleware, writing through ILogger.
Expressdepth/express/logging.tsdepth/express/logging.jslogger
// Express has no logger: pino-http adds one, and logs each
// request when it ends; keep session cookies out of the logs
app.use(
  pinoHttp({ redact: ['req.headers.cookie'] }, stream),
);
// Express has no logger: pino-http adds one, and logs each
// request when it ends; keep session cookies out of the logs
app.use(
  pinoHttp({ redact: ['req.headers.cookie'] }, stream),
);
Fastifydepth/fastify/logging.tsdepth/fastify/logging.jslogger
// the logger is built in, but off until you turn it on; its
// request lines leave out headers, so cookies stay out of the logs
const app = Fastify({ logger: { level: 'info', stream } });
// the logger is built in, but off until you turn it on; its
// request lines leave out headers, so cookies stay out of the logs
const app = Fastify({ logger: { level: 'info', stream } });
ASP.NET Core Like ILogger.LogInformation inside the request's logging scope.
Expressdepth/express/logging.tsdepth/express/logging.jslog-line
app.get('/licences/:ref', (req, res) => {
  req.log.info({ ref: req.params.ref }, 'licence viewed');
  res.send('ok');
});
app.get('/licences/:ref', (req, res) => {
  req.log.info({ ref: req.params.ref }, 'licence viewed');
  res.send('ok');
});
Fastifydepth/fastify/logging.tsdepth/fastify/logging.jslog-line
app.get<{ Params: { ref: string } }>(
  '/licences/:ref',
  async (request) => {
    request.log.info(
      { ref: request.params.ref },
      'licence viewed',
    );
    return 'ok';
  },
);
app.get('/licences/:ref', async (request) => {
  request.log.info(
    { ref: request.params.ref },
    'licence viewed',
  );
  return 'ok';
});
LineExpress, with pino-httpFastify
when the request arrivesnone"incoming request", with the request's reqId
your own linereq.log.info(...): the request is attached under req, with its idrequest.log.info(...), with reqId
when the request ends"request completed", with res.statusCode"request completed", with res.statusCode
Gotcha
Keep the session cookie out of the logs. pino-http's request lines include every header, the cookie among them, unless you redact it. Fastify's request lines leave headers out. The tests send a cookie and check that its value appears in no line.

Plugins and decorators Fastify only#

Everything in Fastify is a plugin, and each plugin has its own scope. What a plugin adds, such as a hook, a decorator or an error handler, reaches only the routes inside it and the plugins it registers. fastify-plugin removes the scope, so what the plugin adds is shared with the whole app. A decorator adds a property to the app, to every request or to every reply.

Fastify's encapsulation: a plain plugin keeps what it adds, fastify-plugin shares it with the app, and a request decorator gets its value in a hook. Fastify's encapsulation. Inside the app, a plain plugin decorates the instance with shout: its own routes can use shout, but the app and other plugins cannot, so the app's hasDecorator('shout') is false. A plugin wrapped with fastify-plugin decorates the instance with config: that is shared with the app and every plugin, so a route on the app, app.get('/service'), can read this.config, but not this.shout. A request decorator is declared once with decorateRequest('user', null), and an onRequest hook gives each request its own value, because Fastify 5 refuses an object shared by every request. THE APP: const app = Fastify() a plain plugin child.decorate('shout', …) its own routes can use shout the app and other plugins cannot wrapped with fastify-plugin fp(async (child) => …) child.decorate('config', …) shared with the app and every plugin shared app.get('/service', …) this.config works here; this.shout does not exist decorateRequest('user', null) then an onRequest hook gives each request its own value
Figure Fastify's encapsulation: a plain plugin keeps what it adds, fastify-plugin shares it with the app, and a request decorator gets its value in a hook.#
As text

Fastify's encapsulation. Inside the app, a plain plugin decorates the instance with shout: its own routes can use shout, but the app and other plugins cannot, so the app's hasDecorator('shout') is false. A plugin wrapped with fastify-plugin decorates the instance with config: that is shared with the app and every plugin, so a route on the app, app.get('/service'), can read this.config, but not this.shout. A request decorator is declared once with decorateRequest('user', null), and an onRequest hook gives each request its own value, because Fastify 5 refuses an object shared by every request.

depth/fastify/encapsulation.tsdepth/fastify/encapsulation.jsscoped
// a plain plugin: what it adds stays inside it
await app.register(async (child) => {
  child.decorate('shout', (text: string) =>
    text.toUpperCase(),
  );
  child.get('/inside', async () => child.shout!('inside'));
});
// a plain plugin: what it adds stays inside it
await app.register(async (child) => {
  child.decorate('shout', (text) => text.toUpperCase());
  child.get('/inside', async () => child.shout('inside'));
});
depth/fastify/encapsulation.tsdepth/fastify/encapsulation.jsshared
// wrapped with fastify-plugin: what it adds is shared with the app
await app.register(
  fp(async (child) => {
    child.decorate('config', {
      serviceName: 'Apply for a juggling licence',
    });
  }),
);
app.get('/service', async function () {
  return this.config.serviceName;
});
// wrapped with fastify-plugin: what it adds is shared with the app
await app.register(
  fp(async (child) => {
    child.decorate('config', {
      serviceName: 'Apply for a juggling licence',
    });
  }),
);
app.get('/service', async function () {
  return this.config.serviceName;
});
depth/fastify/encapsulation.tsdepth/fastify/encapsulation.jsrequest
// a request decorator: declared once, then given a new value
// for each request in a hook
app.decorateRequest('user', null);
app.addHook('onRequest', async (request) => {
  const user = request.headers['x-user'];
  request.user = typeof user === 'string' ? user : null;
});
app.get(
  '/whoami',
  async (request) => request.user ?? 'nobody',
);
// a request decorator: declared once, then given a new value
// for each request in a hook
app.decorateRequest('user', null);
app.addHook('onRequest', async (request) => {
  const user = request.headers['x-user'];
  request.user = typeof user === 'string' ? user : null;
});
app.get(
  '/whoami',
  async (request) => request.user ?? 'nobody',
);
TypeScript only

TypeScript learns about each decorator through declaration merging, as it does for the session:

depth/fastify/encapsulation.tstypes
// TypeScript learns about decorators through declaration merging
declare module 'fastify' {
  interface FastifyInstance {
    config: { serviceName: string };
    shout?: (text: string) => string;
  }
  interface FastifyRequest {
    user: string | null;
  }
}

Express has no scopes. Middleware added with app.use() reaches every route added after it, and values shared with every template go on app.locals.

TypeScript only

Types for a handler written on its own#

Inside a route, TypeScript works out the parameters' types from the path. A handler written on its own, in another file, needs them spelled out:

ASP.NET Core Like binding a handler's parameters with [FromRoute], [FromForm] and [FromQuery].
Expressdepth/express/types.tsdepth/express/types.jshandler
type Params = { ref: string };
type Body = { years?: string };
type Query = { change?: string };

// RequestHandler<Params, ResBody, ReqBody, Query>
export const renew: RequestHandler<
  Params,
  string,
  Body,
  Query
> = (req, res) => {
  const years = Number(req.body.years ?? 1);
  const changing =
    req.query.change === 'true' ? ', changing' : '';
  res.send(
    `renew ${req.params.ref} for ${years} years${changing}`,
  );
};
// RequestHandler<Params, ResBody, ReqBody, Query>
export const renew = (req, res) => {
  const years = Number(req.body.years ?? 1);
  const changing =
    req.query.change === 'true' ? ', changing' : '';
  res.send(
    `renew ${req.params.ref} for ${years} years${changing}`,
  );
};
Fastifydepth/fastify/types.tsdepth/fastify/types.jshandler
type Renewal = {
  Params: { ref: string };
  Body: { years?: string };
  Querystring: { change?: string };
};

export const renew: RouteHandler<Renewal> = async (
  request,
) => {
  const years = Number(request.body.years ?? 1);
  const changing =
    request.query.change === 'true' ? ', changing' : '';
  return `renew ${request.params.ref} for ${years} years${changing}`;
};
export const renew = async (request) => {
  const years = Number(request.body.years ?? 1);
  const changing =
    request.query.change === 'true' ? ', changing' : '';
  return `renew ${request.params.ref} for ${years} years${changing}`;
};
You needExpressFastify
a requestRequest<Params, ResBody, ReqBody, Query>FastifyRequest<{ Params, Querystring, Body }>
a responseResponseFastifyReply
a handlerRequestHandler<Params, ResBody, ReqBody, Query>RouteHandler<{ Params, Querystring, Body }>
an error handlerErrorRequestHandlerthe function you pass to setErrorHandler
a group of routesRouterFastifyPluginAsync<Options>

Testing an app#

Both frameworks are tested by building the app and sending it requests. With Express, the test listens on port 0, which picks a free port, and calls it with fetch. Fastify's inject() sends the request straight to the app, with no port and no network.

ASP.NET Core Like WebApplicationFactory: inject() is Fastify's in-memory test server.
Expresstest/depth-testing.test.tstest/depth-testing.test.jsexpress-fetch
test('express: the health check', async () => {
  const server = createAppWithHealth().listen(0, '127.0.0.1');
  await once(server, 'listening');
  const { port } = server.address() as AddressInfo;
  try {
    const res = await fetch(
      `http://127.0.0.1:${port}/health`,
    );
    assert.equal(res.status, 200);
    assert.equal(
      res.headers.get('cache-control'),
      'no-store',
    );
    assert.deepEqual(await res.json(), {
      status: 'ok',
      version: '1.2.3',
    });
  } finally {
    server.close();
  }
});
test('express: the health check', async () => {
  const server = createAppWithHealth().listen(0, '127.0.0.1');
  await once(server, 'listening');
  const { port } = server.address();
  try {
    const res = await fetch(
      `http://127.0.0.1:${port}/health`,
    );
    assert.equal(res.status, 200);
    assert.equal(
      res.headers.get('cache-control'),
      'no-store',
    );
    assert.deepEqual(await res.json(), {
      status: 'ok',
      version: '1.2.3',
    });
  } finally {
    server.close();
  }
});
Fastifytest/depth-testing.test.tstest/depth-testing.test.jsfastify-inject
test('fastify: the health check', async () => {
  const app = await buildAppWithHealth();
  try {
    const res = await app.inject({
      method: 'GET',
      url: '/health',
    });
    assert.equal(res.statusCode, 200);
    assert.equal(res.headers['cache-control'], 'no-store');
    assert.deepEqual(res.json(), {
      status: 'ok',
      version: '1.2.3',
    });
  } finally {
    await app.close();
  }
});
test('fastify: the health check', async () => {
  const app = await buildAppWithHealth();
  try {
    const res = await app.inject({
      method: 'GET',
      url: '/health',
    });
    assert.equal(res.statusCode, 200);
    assert.equal(res.headers['cache-control'], 'no-store');
    assert.deepEqual(res.json(), {
      status: 'ok',
      version: '1.2.3',
    });
  } finally {
    await app.close();
  }
});

You will also meet supertest in Express tests. It starts the app on a free port itself, and chains its checks:

test/depth-testing.test.tstest/depth-testing.test.jsexpress-supertest
test('express: the health check, with supertest', async () => {
  await request(createAppWithHealth())
    .get('/health')
    .expect(200)
    .expect('Cache-Control', 'no-store')
    .expect({ status: 'ok', version: '1.2.3' });
});
test('express: the health check, with supertest', async () => {
  await request(createAppWithHealth())
    .get('/health')
    .expect(200)
    .expect('Cache-Control', 'no-store')
    .expect({ status: 'ok', version: '1.2.3' });
});

Reading Express 4 and Fastify 4 code#

Many services you join started on the previous major versions. The code in this section runs in the example app's tests on both versions, through npm aliases: express4 and fastify4 install the old versions beside the new.

The biggest change in Express 5 is invisible. Express 4 ignores the promise an async handler returns, so a rejection never reaches the error handler:

legacy/express4/async-error.cjsunwrapped
const app = express();
app.get('/licences/:id', async (req, res) => {
  throw new Error('licensing API failed');
});
app.use((err, req, res, next) => {
  res.status(500).send('handled: ' + err.message);
});

On Express 4 the request above gets no answer at all, and Node reports an unhandled rejection. On Express 5, the error handler answers with a 500. That is why Express 4 code wraps its async handlers, as the service in "Read it" below does.

Express 4 codeWhat it didIn Express 5
app.get('/old/*', ...) and req.params[0]an unnamed wildcarda named one, /old/*rest, and req.params.rest is a list
/search/:term?an optional parameter/search{/:term}
res.redirect('back')back to the Referer page, or /res.redirect(req.get('Referrer') || '/')
res.redirect(url, 301)a redirect, with the status secondres.redirect(301, url)
res.send(404)a bare statusres.sendStatus(404)
res.json(obj, 201)the status secondres.status(201).json(obj)
req.param('name')the value from the path, body or queryreq.params, req.body or req.query
app.del()a DELETE routeapp.delete()
a wrap() around async handlerspassing a rejection to nextnot needed
req.body with no parser{}undefined
express.staticserved dotfilesignores them unless dotfiles: 'allow'

Express 5 refuses this book's Express 4 service outright: its first Express 4 path throws a TypeError as the route is added. Fastify 4 code is similar:

legacy/fastify4/app.cjsapp
const app = Fastify();

// one object shared by every request: Fastify 5 refuses this
app.decorateRequest('user', { name: 'nobody' });

app.get(
  '/licences',
  {
    // a short-hand schema: Fastify 5 needs type: 'object' and properties
    schema: { querystring: { page: { type: 'integer', default: 1 } } },
  },
  async (request) => ({
    page: request.query.page,
    route: request.routerPath, // now request.routeOptions.url
    hostname: request.hostname, // had the port in Fastify 4
  }),
);

app.get('/old', async (request, reply) =>
  reply.redirect(301, '/new'), // now reply.redirect('/new', 301)
);
Fastify 4 codeWhat it didIn Fastify 5
reply.redirect(301, url)the status firstreply.redirect(url, 301)
querystring: { page: { type: 'integer' } }a short-hand schemaa full schema, with type: 'object' and properties
request.routerPath, request.routeConfigthe route's definitionrequest.routeOptions.url, request.routeOptions.config
decorateRequest('user', {})one object, shared by every requestrefused: declare it with null, and set it in an onRequest hook
request.hostnamethe host name and the portrequest.host; hostname has no port
listen(3000)a portlisten({ port: 3000 })
logger: pino()your own pino instanceloggerInstance: pino()
reply.getResponseTime()the time takenreply.elapsedTime
an async plugin that also calls doneallowedan error: use one style or the other

Read it: an Express 4 service#

This is the kind of code you will find in an older service. Read it, then the notes.

legacy/express4/app.cjsapp
const app = express();
app.use(bodyParser.urlencoded({ extended: false }));

// Express 4 ignores a rejected promise, so async handlers are wrapped
const wrap = (handler) => (req, res, next) =>
  Promise.resolve(handler(req, res, next)).catch(next);

app.get(
  '/licences/:id',
  wrap(async (req, res) => {
    const licence = await findLicence(req.params.id);
    if (!licence) return res.sendStatus(404);
    res.send(licence.holder);
  }),
);

app.post('/licences/:id/renew', (req, res) => {
  res.redirect('back');
});

app.get('/search/:term?', (req, res) => {
  res.send(req.params.term || 'everything');
});

app.get('/old/*', (req, res) => {
  res.redirect(301, '/new/' + req.params[0]);
});

app.use((err, req, res, next) => {
  res.status(500).send('Sorry, there is a problem with the service');
});
  1. Form bodies, through the body-parser package. Express 5 code calls express.urlencoded() instead.
  2. Express 4 ignores the promise an async handler returns. The wrapper catches a rejection and passes it to next, so that the error handler sees it.
  3. A status on its own, with its text as the body: here, "Not Found".
  4. Back to the page in the Referer header, or to /. Express 5 removed 'back'.
  5. An optional parameter, marked with ?. Express 5 writes /search{/:term}.
  6. An unnamed wildcard, read as req.params[0]. Express 5 needs a name.
  7. Four parameters make this the error handler. It comes last, and receives what the wrapper passed to next.

Write it: a health check#

The task: add /health, which a load balancer calls to decide whether to send traffic to this copy of the service. It answers with the version, is never cached, and stays out of the logs.

ASP.NET Core Like MapHealthChecks, answering at /health.
Expressdepth/express/health.tsdepth/express/health.jsroutes
export function healthRoutes(version: string) {
  const router = Router();
  router.get('/health', (req, res) => {
    res.set('Cache-Control', 'no-store');
    res.json({ status: 'ok', version });
  });
  return router;
}
export function healthRoutes(version) {
  const router = Router();
  router.get('/health', (req, res) => {
    res.set('Cache-Control', 'no-store');
    res.json({ status: 'ok', version });
  });
  return router;
}
Fastifydepth/fastify/health.tsdepth/fastify/health.jsroutes
export const healthRoutes: FastifyPluginAsync<{
  version: string;
}> = async (app, options) => {
  // the load balancer calls this every few seconds:
  // keep it out of the logs
  app.get(
    '/health',
    { logLevel: 'silent' },
    async (request, reply) => {
      reply.header('Cache-Control', 'no-store');
      return { status: 'ok', version: options.version };
    },
  );
};
export const healthRoutes = async (app, options) => {
  // the load balancer calls this every few seconds:
  // keep it out of the logs
  app.get(
    '/health',
    { logLevel: 'silent' },
    async (request, reply) => {
      reply.header('Cache-Control', 'no-store');
      return { status: 'ok', version: options.version };
    },
  );
};

Fastify takes the version as a plugin option, and turns the route's logging off with logLevel: 'silent'. Adding it to the service is one line in each:

ASP.NET Core Like adding one more endpoint in Program.cs.
Expressdepth/express/health.tsdepth/express/health.jsmount
export function createAppWithHealth() {
  return createApp([healthRoutes('1.2.3')]);
}
export function createAppWithHealth() {
  return createApp([healthRoutes('1.2.3')]);
}
Fastifydepth/fastify/health.tsdepth/fastify/health.jsmount
export async function buildAppWithHealth() {
  const app = await buildApp();
  await app.register(healthRoutes, { version: '1.2.3' });
  return app;
}
export async function buildAppWithHealth() {
  const app = await buildApp();
  await app.register(healthRoutes, { version: '1.2.3' });
  return app;
}

Its tests are the pair in "Testing an app", above.

On the job#
Find it
Open the file that builds the app and read it top to bottom: the order of middleware or plugins, then the error handler. Check the Express or Fastify major version in package.json before you trust an example.
Add it
Build the app in a function the tests can call. Add an error handler that shows a GOV.UK page, a health check, and request logging with the cookie redacted.

The plumbing#

Part II · The server · Chapter 06·3 min read

Every GOV.UK service needs the same plumbing before its first page works. Here it is in order, in both frameworks, with the traps.

The plumbing in the order a request meets it: security headers, static files, form bodies, session, CSRF check, then routes. The plumbing a request passes through, in order, in both Express and Fastify. First security headers, which also create the nonce for inline scripts. Then static files, which serves GOV.UK Frontend's files at /assets and /govuk. Then the form body parser, which fills the request body. Then the session, which fills the request session from the cookie. Then the CSRF check, which answers 403 when a posted form has no valid token. Then the routes, which render a page or redirect. Order matters: the CSRF check needs the session and the parsed body, and the templates need the nonce. security headers static files form bodies session CSRF check routes headers, nonce /assets, /govuk fills req.body fills req.session 403 if no token render or redirect Order matters. The CSRF check needs the session and the parsed body, so it comes after both. The templates need the nonce, so headers come first. Anything that fails lands in the 404 and error handlers, which come last.
Figure The plumbing in the order a request meets it: security headers, static files, form bodies, session, CSRF check, then routes.#
As text

The plumbing a request passes through, in order, in both Express and Fastify. First security headers, which also create the nonce for inline scripts. Then static files, which serves GOV.UK Frontend's files at /assets and /govuk. Then the form body parser, which fills the request body. Then the session, which fills the request session from the cookie. Then the CSRF check, which answers 403 when a posted form has no valid token. Then the routes, which render a page or redirect. Order matters: the CSRF check needs the session and the parsed body, and the templates need the nonce.

JobExpressFastify
security headers and the CSP noncehelmet@fastify/helmet
GOV.UK Frontend's filesexpress.static, built in@fastify/static
form bodiesexpress.urlencoded(), built in@fastify/formbody
cookieshandled inside express-session@fastify/cookie
sessionsexpress-session@fastify/session
CSRF tokenscsrf-sync@fastify/csrf-protection
templatesNunjucks, with configure({ express: app })@fastify/view

Sessions#

If you have kept a token in local storage, a session works the other way round. The browser holds only an id, in a cookie, and the answers stay on the server in a session store.

A session: the cookie carries only an id, and the answers live in a store on the server, which is memory unless you choose another. How a session works. The browser holds only a session cookie containing an id. On each request the Node frontend reads the id and loads that session's data, such as the answers so far, from a session store on the server. With no store configured, both express-session and @fastify/session use memory, which loses every session when the process restarts and is not shared between instances. A shared store survives restarts. This is the reverse of keeping a token in the browser's local storage: here the answers never leave the server. Browser session cookie: an id, nothing else id Node frontend reads the id, loads req.session get, set Session store id: { answers } on the server memory, the default: lost on restart, one instance only a shared store: survives restarts, shared by instances Not a token in local storage: the answers never reach the browser, and the cookie is useless without the server that issued it.
Figure A session: the cookie carries only an id, and the answers live in a store on the server, which is memory unless you choose another.#
As text

How a session works. The browser holds only a session cookie containing an id. On each request the Node frontend reads the id and loads that session's data, such as the answers so far, from a session store on the server. With no store configured, both express-session and @fastify/session use memory, which loses every session when the process restarts and is not shared between instances. A shared store survives restarts. This is the reverse of keeping a token in the browser's local storage: here the answers never leave the server.

ASP.NET Core Like AddSession() and UseSession(), with the cookie options set explicitly.
Expressexpress/app.tsexpress/app.jssession
app.use(
  session({
    secret: sessionSecret,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      sameSite: 'lax',
      secure: process.env.NODE_ENV === 'production',
    },
  }),
);
app.use(
  session({
    secret: sessionSecret,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      sameSite: 'lax',
      secure: process.env.NODE_ENV === 'production',
    },
  }),
);
Fastifyfastify/app.tsfastify/app.jssession
await app.register(fastifyCookie);
await app.register(fastifySession, {
  secret: sessionSecret,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    sameSite: 'lax',
    secure: process.env.NODE_ENV === 'production',
  },
});
await app.register(fastifyCookie);
await app.register(fastifySession, {
  secret: sessionSecret,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    sameSite: 'lax',
    secure: process.env.NODE_ENV === 'production',
  },
});
Gotcha
The default session store is memory. Both packages keep sessions inside the Node process unless you pass a store, and express-session's own documentation warns against that in production. Sessions vanish on restart and are not shared between instances, so a real service uses a shared store.
Gotcha
@fastify/session sets Secure cookies by default. Over plain HTTP on your machine the session never sticks, so every request starts a new one. Set secure from the environment, as the example does.
TypeScript only

Tell each framework what the session holds, with declaration merging. Then answers and reference are typed wherever you touch the session.

express/routes.tssession-type
declare module 'express-session' {
  interface SessionData {
    answers: Answers;
    reference: string;
  }
}
fastify/routes.tssession-type
declare module 'fastify' {
  interface Session {
    answers?: Answers;
    reference?: string;
  }
}

CSRF protection#

Each form carries the token in a hidden _csrf field, and the server checks it on every POST.

ASP.NET Core Like the antiforgery token that Razor forms add for you.
Expressexpress/app.tsexpress/app.jscsrf
const { csrfSynchronisedProtection, generateToken } =
  csrfSync({
    // forms post the token in a hidden field, not a header
    getTokenFromRequest: (req) => req.body?._csrf,
  });
app.use(csrfSynchronisedProtection);
app.use((req, res, next) => {
  res.locals.csrfToken = generateToken(req);
  next();
});
const { csrfSynchronisedProtection, generateToken } =
  csrfSync({
    // forms post the token in a hidden field, not a header
    getTokenFromRequest: (req) => req.body?._csrf,
  });
app.use(csrfSynchronisedProtection);
app.use((req, res, next) => {
  res.locals.csrfToken = generateToken(req);
  next();
});
Fastifyfastify/app.tsfastify/app.jscsrf
await app.register(fastifyCsrf, {
  sessionPlugin: '@fastify/session',
});
app.addHook('preHandler', (request, reply, done) => {
  reply.locals = {
    cspNonce: reply.cspNonce.script,
    csrfToken: reply.generateCsrf(),
  };
  done();
});
// check the token in preHandler: the form body is parsed by then
app.addHook('preHandler', (request, reply, done) => {
  if (request.method !== 'POST') return done();
  app.csrfProtection(request, reply, done);
});
await app.register(fastifyCsrf, {
  sessionPlugin: '@fastify/session',
});
app.addHook('preHandler', (request, reply, done) => {
  reply.locals = {
    cspNonce: reply.cspNonce.script,
    csrfToken: reply.generateCsrf(),
  };
  done();
});
// check the token in preHandler: the form body is parsed by then
app.addHook('preHandler', (request, reply, done) => {
  if (request.method !== 'POST') return done();
  app.csrfProtection(request, reply, done);
});
Gotcha
The token has to be read from the form body. csrf-sync looks in the x-csrf-token header unless you pass getTokenFromRequest. In Fastify, run the check in preHandler: an onRequest hook runs before the body is parsed, so the token is not there yet.

Not found and error pages#

Both apps finish with a 404 page and an error page. A rejected CSRF token arrives as a 403, so it gets its own message.

ASP.NET Core Like UseStatusCodePages and UseExceptionHandler.
Expressexpress/app.tsexpress/app.jserrors
app.use((req: Request, res: Response) => {
  res.status(404).render('error', {
    title: 'Page not found',
    message:
      'If you typed the web address, check it is correct.',
  });
});
app.use(
  (
    err: { status?: number },
    req: Request,
    res: Response,
    next: NextFunction,
  ) => {
    if (err.status === 403) {
      return res.status(403).render('error', {
        title: 'Sorry, you need to start again',
        message: 'Your form expired or was sent twice.',
      });
    }
    res.status(500).render('error', {
      title: 'Sorry, there is a problem with the service',
      message: 'Try again later.',
    });
  },
);
app.use((req, res) => {
  res.status(404).render('error', {
    title: 'Page not found',
    message:
      'If you typed the web address, check it is correct.',
  });
});
app.use((err, req, res, next) => {
  if (err.status === 403) {
    return res.status(403).render('error', {
      title: 'Sorry, you need to start again',
      message: 'Your form expired or was sent twice.',
    });
  }
  res.status(500).render('error', {
    title: 'Sorry, there is a problem with the service',
    message: 'Try again later.',
  });
});
Fastifyfastify/app.tsfastify/app.jserrors
app.setNotFoundHandler((request, reply) =>
  reply.code(404).view('error', {
    title: 'Page not found',
    message:
      'If you typed the web address, check it is correct.',
  }),
);
app.setErrorHandler(
  (error: { statusCode?: number }, request, reply) => {
    if (error.statusCode === 403) {
      return reply.code(403).view('error', {
        title: 'Sorry, you need to start again',
        message: 'Your form expired or was sent twice.',
      });
    }
    return reply.code(500).view('error', {
      title: 'Sorry, there is a problem with the service',
      message: 'Try again later.',
    });
  },
);
app.setNotFoundHandler((request, reply) =>
  reply.code(404).view('error', {
    title: 'Page not found',
    message:
      'If you typed the web address, check it is correct.',
  }),
);
app.setErrorHandler((error, request, reply) => {
  if (error.statusCode === 403) {
    return reply.code(403).view('error', {
      title: 'Sorry, you need to start again',
      message: 'Your form expired or was sent twice.',
    });
  }
  return reply.code(500).view('error', {
    title: 'Sorry, there is a problem with the service',
    message: 'Try again later.',
  });
});
On the job#
Find it
Search for express-session or @fastify/session and check which store production uses. Then check that the CSRF check reads the token from the form body.
Add it
Add the plumbing in this order: headers, static files, form bodies, session, CSRF, then routes, then the 404 and error handlers.

Nunjucks for JSX and Razor developers#

Part III · The views · Chapter 07·3 min read

Nunjucks is the template language GOV.UK Frontend's components are written for. This chapter starts with Nunjucks on its own, then maps JSX and Razor onto it.

Nunjucks from zero#

Nunjucks is a template engine for JavaScript, from Mozilla, installed with npm install nunjucks. A template is HTML with two kinds of tag. {{ ... }} outputs a value, escaped. {% ... %} runs logic such as if, for, set, extends, block, include and macro. It runs on the server, once per request, and the browser only ever sees the HTML it produces.

Nunjucks needs no web framework. Point it at a folder of templates, then render one with some data:

hello/nunjucks.tshello/nunjucks.jsrender
const env = nunjucks.configure(
  join(import.meta.dirname, 'views'),
  { autoescape: true },
);
return env.render('hello.njk', {
  name: '<Ada>',
  props: ['clubs', 'rings'],
});
const env = nunjucks.configure(
  join(import.meta.dirname, 'views'),
  { autoescape: true },
);
return env.render('hello.njk', {
  name: '<Ada>',
  props: ['clubs', 'rings'],
});
hello/views/hello.njktemplate
<h1>Hello, {{ name }}</h1>
{% if props | length %}
  <ul>
    {% for prop in props %}
      <li>{{ prop | capitalize }}</li>
    {% endfor %}
  </ul>
{% endif %}

The result is a string of HTML. The heading comes out as <h1>Hello, &lt;Ada&gt;</h1>: Nunjucks escaped the < and >, so a name can never become a tag. The capitalize filter changed each prop. Nunjucks leaves a blank line where each tag was; with those removed, the list is:

<ul>
    <li>Clubs</li>
    <li>Rings</li>
</ul>
You writeWhat it doesExample
{{ value }}outputs a value, escaped{{ name }}
{{ value | filter }}passes the value through a filter first{{ prop | capitalize }}, {{ props | length }}
{% tag %}runs logic{% if props | length %}
{% set %}gives a value a name{% set title = "Your name" %}
{# ... #}a comment, never sent to the browser{# the first template #}
{%- ... -%}trims the whitespace before or after a tag{%- if ok -%}

Chapter 8 covers every expression, filter, tag and scoping rule, with a rendered example of each. This chapter maps the parts you use most onto what you know.

From JSX#

React (JSX)you know this
function Answers({ rows, reference }) {
  return (
    <>
      {reference && <p>Your reference is {reference}</p>}
      <dl>
        {rows.map((row) => (
          <div key={row.key}>
            <dt>{row.key}</dt>
            <dd>{row.value}</dd>
          </div>
        ))}
      </dl>
    </>
  );
}
Nunjucks
{% if reference %}
  <p>Your reference is {{ reference }}</p>
{% endif %}
<dl>
  {% for row in rows %}
    <div>
      <dt>{{ row.key }}</dt>
      <dd>{{ row.value }}</dd>
    </div>
  {% endfor %}
</dl>

Layouts and blocks#

Layouts work like Razor's _Layout.cshtml. A page names its parent with extends, and fills the parent's named blocks, where Razor would fill sections.

Razoryou know this
@* _Layout.cshtml *@
<title>@ViewData["Title"]</title>
<main>@RenderBody()</main>
@RenderSection("Scripts", required: false)

@* Name.cshtml *@
@{
  Layout = "_Layout";
  ViewData["Title"] = "What is your full name?";
}
<form method="post">...</form>
Nunjucks
{# layout.njk #}
<title>{% block pageTitle %}{% endblock %}</title>
<main>{% block page %}{% endblock %}</main>
{% block bodyEnd %}{% endblock %}

{# pages/name.njk #}
{% extends "layout.njk" %}
{% set title = "What is your full name?" %}
{% block page %}<form method="post">...</form>{% endblock %}
Template inheritance: each page extends the service's layout, which extends GOV.UK Frontend's page template. Template inheritance, three levels. GOV.UK Frontend's govuk/template.njk declares empty blocks: pageTitle, head, header, beforeContent, content, footer and bodyEnd. The service's layout.njk extends it and fills pageTitle with an Error prefix and the title, head with the stylesheet, beforeContent with the back link, content with the error summary and a new block called page, and bodyEnd with the scripts; it keeps the header and footer as they are. Each question page, such as pages/name.njk, extends layout.njk, sets the title and fills the page block with its form. Like a Razor layout with sections, or a React layout component whose children are the page. govuk/template.njk from GOV.UK Frontend block pageTitle block head block header block beforeContent block content block footer block bodyEnd layout.njk yours: the whole service pageTitle: Error: + title head: the stylesheet header: kept as it is beforeContent: back link content: error summary, then block page footer: kept as it is bodyEnd: the scripts pages/name.njk one per question page set title = "What is your full name?" page: the form extends extends
Figure Template inheritance: each page extends the service's layout, which extends GOV.UK Frontend's page template.#
As text

Template inheritance, three levels. GOV.UK Frontend's govuk/template.njk declares empty blocks: pageTitle, head, header, beforeContent, content, footer and bodyEnd. The service's layout.njk extends it and fills pageTitle with an Error prefix and the title, head with the stylesheet, beforeContent with the back link, content with the error summary and a new block called page, and bodyEnd with the scripts; it keeps the header and footer as they are. Each question page, such as pages/name.njk, extends layout.njk, sets the title and fills the page block with its form. Like a Razor layout with sections, or a React layout component whose children are the page.

JSX and Razor, translated#

You writeIn NunjucksNote
{value}, @Model.Value{{ value }}escaped by default, like both of them
{ok && <X />}, @if (ok){% if ok %}...{% endif %}also elif and else
items.map(...), @foreach{% for item in items %}loop.index counts from 1
a component and its props{% macro name(params) %}, called as {{ name({...}) }}GOV.UK Frontend ships one macro per component
children{% call %}...{% endcall %}, rendered with caller()the macro renders the block you pass
a layout or _Layout.cshtml{% extends "layout.njk" %}one parent per template
a section or a slot{% block name %}{% endblock %}the child fills it, or the default stays
a partial view{% include "x.njk" %}sees the current variables
@Html.Raw, dangerouslySetInnerHTML{{ html | safe }}turns escaping off: only for HTML you built
a tag helpera macro call{{ govukInput({...}) }} instead of <input asp-for>
useState, HttpContext.Sessionreq.session, on the serverthe routes write it; the template only reads what they pass
ModelState errorserrorList and fieldErrorsbuilt by your validator: see chapter 11

Macros, text and html#

Every GOV.UK Frontend component is a macro that takes one object of options. Most text options come in pairs: text is escaped and html is not.

A macro is a component: one options object in, the accessible HTML out, with the label, the error and the input wired together. A macro is a component: one options object in, accessible HTML out. The call govukInput with a label text, isPageHeading true, an id and name of fullName, a value and an errorMessage renders a form group with the error class. Inside it, isPageHeading wraps the label in an h1, and the label's for attribute points at the input. The error message renders as a paragraph with the id fullName-error, starting with the words "Error:" hidden visually for screen readers. The input gets the id fullName, keeps the value the user typed, and has aria-describedby set to fullName-error, so screen readers read the message with the field. THE CALL govukInput({ label: { text: "What is your full name?", isPageHeading: true }, id: "fullName", name: "fullName", value: values.fullName, errorMessage: fieldErrors.fullName }) THE HTML IT RENDERS <div class="govuk-form-group--error"> <h1><label for="fullName"> What is your full name? </label></h1> <p id="fullName-error"> <span hidden>Error:</span> Enter … <input id="fullName" name="fullName" value="Ada" aria-describedby="fullName-error"> </div> isPageHeading wraps the label in the page's h1, so the question is the heading. id becomes the input's id and the label's for, which the error summary links to. errorMessage adds the message, with a hidden "Error:" for screen readers, and aria-describedby, so the message is read out with the field.
Figure A macro is a component: one options object in, the accessible HTML out, with the label, the error and the input wired together.#
As text

A macro is a component: one options object in, accessible HTML out. The call govukInput with a label text, isPageHeading true, an id and name of fullName, a value and an errorMessage renders a form group with the error class. Inside it, isPageHeading wraps the label in an h1, and the label's for attribute points at the input. The error message renders as a paragraph with the id fullName-error, starting with the words "Error:" hidden visually for screen readers. The input gets the id fullName, keeps the value the user typed, and has aria-describedby set to fullName-error, so screen readers read the message with the field.

To pass HTML safely, build it in a {% set %} block, where {{ reference }} is still escaped:

views/pages/confirmation.njkpanel
{% set panelHtml %}
  Your reference number<br><strong>{{ reference }}</strong>
{% endset %}
{{ govukPanel({ titleText: title, html: panelHtml }) }}
Gotcha
Templates are not type-checked. Razor compiles against its @model, and TypeScript checks your JSX props. Nunjucks does neither: a misspelt {{ user.nmae }} renders as nothing, with no error. Nunjucks has a throwOnUndefined option, but it is off by default. Render every page in a test, as the example does.
On the job#
Find it
Find the folder list passed to nunjucks.configure() or to @fastify/view. Every template name, such as govuk/template.njk, is looked up in those folders, in order.
Add it
Put a layout.njk that extends govuk/template.njk in your views folder, and make every page extend it.

Nunjucks in depth#

Part III · The views · Chapter 08·11 min read

The whole Nunjucks template language, with a rendered example of every feature, so that you can read any template and write your own.

The environment#

Every template is rendered by an environment: a list of template folders, some settings, and any filters and globals you add. nunjucks.configure(), which the service uses, creates one and returns it. You can also create one yourself, which makes the folders and settings explicit:

depth/nunjucks/env.tsdepth/nunjucks/env.jsenvironment
const env = new nunjucks.Environment(
  new nunjucks.FileSystemLoader([
    join(views, 'service'), // searched first
    join(views, 'shared'),
  ]),
  { autoescape: true, throwOnUndefined: false },
);
const env = new nunjucks.Environment(
  new nunjucks.FileSystemLoader([
    join(views, 'service'), // searched first
    join(views, 'shared'),
  ]),
  { autoescape: true, throwOnUndefined: false },
);
SettingDefaultWhat it does
autoescapetrueescapes every output; leave it on
throwOnUndefinedfalsefails when a template prints an undefined value, instead of printing nothing
trimBlocksfalseremoves the line break after each tag
lstripBlocksfalseremoves the spaces before a tag at the start of a line
noCachefalsecompiles every template again on every render
watchfalsereloads templates that change on disk; needs the chokidar package
expressnonemakes the environment the Express app's view engine, as the service does

An environment searches its folders in order, and uses the first template it finds with the name asked for:

How a template name is found: each folder in order, the first folder with the name wins, and a name that no folder has fails with "template not found". How Nunjucks finds a template by name. The service configures two search paths in order: first GOV.UK Frontend's dist folder, then the service's own views folder. Asked for govuk/template.njk, Nunjucks finds it in the first folder and stops. Asked for layout.njk, it does not find it in the first folder, so it looks in the second, and finds it there. The first folder that has the name wins, so a file with the same name in a later folder is never used. A name found in no folder fails with "template not found". nunjucks.configure([paths.govukViews, paths.views]) ASKED FOR govuk/template.njk layout.njk 1. GOV.UK FRONTEND'S DIST FOLDER found here: used not here: try the next folder 2. THE SERVICE'S VIEWS FOLDER never looked at found here: used The first folder that has the name wins: a file of the same name in a later folder is never used. A name that no folder has fails with "template not found".
Figure How a template name is found: each folder in order, the first folder with the name wins, and a name that no folder has fails with "template not found".#
As text

How Nunjucks finds a template by name. The service configures two search paths in order: first GOV.UK Frontend's dist folder, then the service's own views folder. Asked for govuk/template.njk, Nunjucks finds it in the first folder and stops. Asked for layout.njk, it does not find it in the first folder, so it looks in the second, and finds it there. The first folder that has the name wins, so a file with the same name in a later folder is never used. A name found in no folder fails with "template not found".

Gotcha
A template of the same name in an earlier folder hides yours. The example environment has a notice.njk in both folders, and every render gets the service folder's. Give your templates names that no folder searched before them uses.

Expressions#

Inside {{ }} and {% %}, Nunjucks has most of JavaScript's operators, and a few of its own. Given a reference, a fee of 20 for 3 years, and a user with no address, this template:

depth/nunjucks/views/service/expressions.njkoperators
{{ "Licence " ~ ref }} |
{{ fee * years }} |
{{ 7 // 2 }} |
{{ "yes" if fire else "no" }} |
{{ "clubs" in props }} |
{{ name.toUpperCase() }} |
{{ r/^JL-\d{4}$/.test(ref) }} |
[{{ user.address.postcode }}]

renders Licence JL-0042 | 60 | 3 | no | true | ADA | true | [].

You writeIt means
a ~ bjoins two values as strings; + also joins when either side is a string, as in JavaScript
+ - * /, //, %, **maths: // divides and drops the fraction, ** is a power
==, !=, ===, !==, <, >, <=, >=comparisons, as in JavaScript
and, or, notlogic, where JavaScript writes &&, || and !
x in listtrue when a list holds x, a string contains x, or an object has the key x
a if test else ban inline if; with no else, it gives nothing when the test fails
r/^JL-\d{4}$/a regular expression, with its test() method
name.toUpperCase()calls a JavaScript method on the value
user.address.postcodea lookup: anything undefined along the way prints nothing, with no error
none, true, falseJavaScript's null, true and false
Gotcha
Form values are strings. {{ "10" < "9" }} is true, because strings compare as text. Convert them first, as in {{ age | int >= 18 }}.

Filters, tests and globals#

A filter changes a value: {{ value | filter }}, or {{ value | filter(argument) }}, and filters chain from left to right. A test asks a question, as in {% if count is odd %}. A global is a function that every template can call. The tables below list every one that Nunjucks 3.2.4 has, and the example app's tests render each example and check the output shown.

Gotcha
default ignores null. It replaces only an undefined value, so a null from an API prints nothing. Write default("Not provided", true) to replace any falsy value.
Gotcha
Some filters' HTML gets escaped. nl2br, urlize and dump return plain text, so autoescaping escapes their tags too. For text a person typed, write {{ text | escape | nl2br | safe }}: the text is escaped first, and only the line breaks become tags.
FilterWhat it doesExampleOutput
absThe absolute value of a number{{ -3 | abs }}3
batchSplits a list into lists of a given size{% for row in [1, 2, 3, 4, 5] | batch(2) %}{{ row | join(",") }}; {% endfor %}1,2; 3,4; 5;
capitalizeUpper-cases the first letter, and lower-cases the rest{{ "cLUBS" | capitalize }}Clubs
centerPads a string with spaces to a width, centred[{{ "hi" | center(6) }}][ hi ]
defaultA value to use when the value is undefined. It leaves null and empty strings alone, unless you pass true as a second argument[{{ none | default("x") }}] [{{ none | default("x", true) }}][] [x]
dShort for default{{ "" | d("Not provided", true) }}Not provided
dictsortAn object's key and value pairs, sorted by key{% for k, v in {"b": 2, "a": 1} | dictsort %}{{ k }}={{ v }} {% endfor %}a=1 b=2
dumpThe value as JSON. Its quotes are then escaped, like any output{{ {"a": 1} | dump }}{&quot;a&quot;:1}
escapeEscapes HTML, and marks the result safe so that it is not escaped twice{{ "<b>" | escape }}&lt;b&gt;
eShort for escape{{ "a & b" | e }}a &amp; b
firstThe first item of a list{{ ["clubs", "rings"] | first }}clubs
floatConverts to a decimal number{{ "3.5" | float + 1 }}4.5
forceescapeEscapes HTML even when the value is marked safe{{ "<b>" | safe | forceescape }}&lt;b&gt;
groupbyGroups a list of objects by one attribute{% for type, items in [{"n": "Ada", "t": "fire"}, {"n": "Bo", "t": "clubs"}, {"n": "Cy", "t": "fire"}] | groupby("t") %}{{ type }}: {{ items | length }}; {% endfor %}fire: 2; clubs: 1;
indentIndents every line after the first{{ "a\nb" | indent(2) }}a b
intConverts to a whole number, dropping any fraction{{ "42.9" | int }}42
joinJoins a list into a string. A second argument joins one attribute of each object{{ ["clubs", "rings"] | join(", ") }}clubs, rings
lastThe last item of a list{{ ["clubs", "rings"] | last }}rings
lengthThe number of items in a list, or of characters in a string; 0 for an undefined value{{ ["clubs", "rings"] | length }}2
listTurns a string into a list of its characters{{ "abc" | list | join("-") }}a-b-c
lowerLower-cases a string{{ "RINGS" | lower }}rings
nl2brTurns line breaks into <br /> tags. Its output is escaped like any other, so escape the text first and mark the result safe{{ "one\ntwo" | escape | nl2br | safe }}one<br /> two
randomOne item from a list, at random{{ ["only"] | random }}only
rejectRemoves the items that pass a test{{ [1, 2, 3, 4] | reject("odd") | join(",") }}2,4
rejectattrRemoves the objects whose attribute is truthy{{ [{"n": "Ada", "done": true}, {"n": "Bo", "done": false}] | rejectattr("done") | join(",", "n") }}Bo
replaceReplaces every match of a string, or of a regular expression, inside a string{{ "FS-123-456" | replace("-", "") }}FS123456
reverseReverses a list or a string{{ [1, 2, 3] | reverse | join(",") }}3,2,1
roundRounds a number to a number of decimal places; "floor" or "ceil" as a second argument rounds down or up{{ 2.567 | round(1) }} {{ 2.567 | round(1, "floor") }}2.6 2.5
safeMarks a string safe, so that it is not escaped. Use it only for HTML you built{{ "<b>bold</b>" | safe }}<b>bold</b>
selectKeeps the items that pass a test{{ [1, 2, 3, 4] | select("even") | join(",") }}2,4
selectattrKeeps the objects whose attribute is truthy{{ [{"n": "Ada", "done": true}, {"n": "Bo", "done": false}] | selectattr("done") | join(",", "n") }}Ada
sliceSplits a list into a number of lists{% for col in [1, 2, 3, 4, 5] | slice(2) %}{{ col | join(",") }}; {% endfor %}1,2,3; 4,5;
sortSorts a list; sort(false, false, "name") sorts objects by one attribute{{ [3, 1, 2] | sort | join(",") }}1,2,3
stringConverts to a string{{ 42 | string | length }}2
striptagsRemoves HTML tags, and collapses the spaces left behind{{ "<p>Hello <b>Ada</b></p>" | striptags }}Hello Ada
sumAdds up a list of numbers{{ [1, 2, 3] | sum }}6
titleCapitalises every word{{ "apply for a licence" | title }}Apply For A Licence
trimRemoves spaces from both ends[{{ " Ada " | trim }}][Ada]
truncateCuts a string to a length, at a word boundary, and adds ...{{ "Apply for a juggling licence" | truncate(12) }}Apply for a...
upperUpper-cases a string{{ "clubs" | upper }}CLUBS
urlencodeEncodes a string for use in a URL{{ "a b&c" | urlencode }}a%20b%26c
urlizeTurns web addresses in text into links. Its output is escaped unless you add safe, which is only right for text you wrote{{ "See https://www.gov.uk" | urlize | safe }}See <a href="https://www.gov.uk">https://www.gov.uk</a>
wordcountCounts the words in a string{{ "one two three" | wordcount }}3
TestWhat it doesExampleOutput
callableThe value is a function{{ range is callable }}true
definedThe value is not undefined{{ missing is defined }}false
divisiblebyThe number divides by another with no remainder{{ 9 is divisibleby(3) }}true
escapedThe value is marked safe{{ "<b>" | safe is escaped }}true
equaltoThe value is identical to another, as with ==={{ 2 is equalto(2) }}true
eqShort for equalto, so a string never equals a number{{ "2" is eq(2) }}false
sameasAnother name for equalto{{ 2 is sameas(2) }}true
evenThe number is even{{ 4 is even }}true
falsyThe value is falsy{{ "" is falsy }}true
geGreater than, or equal to, another{{ 3 is ge(3) }}true
greaterthanGreater than another{{ 3 is greaterthan(2) }}true
gtShort for greaterthan{{ 3 is gt(2) }}true
leLess than, or equal to, another{{ 2 is le(3) }}true
lessthanLess than another{{ 2 is lessthan(3) }}true
ltShort for lessthan{{ 2 is lt(3) }}true
lowerThe string is all lower case{{ "abc" is lower }}true
neNot identical to another, as with !=={{ 2 is ne(3) }}true
nullThe value is null, written none in a template{{ none is null }}true
numberThe value is a number: a form value never is{{ "5" is number }}false
oddThe number is odd{{ 3 is odd }}true
stringThe value is a string{{ "5" is string }}true
truthyThe value is truthy{{ "x" is truthy }}true
undefinedThe value is undefined{{ missing is undefined }}true
upperThe string is all upper case{{ "ABC" is upper }}true
iterableThe value can be looped over{{ [1] is iterable }}true
mappingThe value is an object{{ {"a": 1} is mapping }}true
GlobalWhat it doesExampleOutput
rangeA list of numbers: range(stop), range(start, stop) or range(start, stop, step){{ range(3) | join(",") }} {{ range(1, 10, 3) | join(",") }}0,1,2 1,4,7
cyclerGives its arguments in turn, one for each call to next(){% set row = cycler("odd", "even") %}{% for i in [1, 2, 3] %}{{ row.next() }} {% endfor %}odd even odd
joinerGives nothing the first time it is called, then its separator{% set comma = joiner(", ") %}{% for p in ["clubs", "rings"] %}{{ comma() }}{{ p }}{% endfor %}clubs, rings

if and for#

if takes elif and else. for loops over a list, over an object's keys and values, or over anything iterable such as a Map. Its else runs when there is nothing to loop over. Inside the loop, loop tells you where you are. A minus sign inside a tag's brackets removes the whitespace on that side of the tag:

depth/nunjucks/views/service/loops.njkloops
{% for prop in props -%}
  {{ loop.index }} of {{ loop.length }}: {{ prop }}{{ "," if not loop.last }}
{% else -%}
  No props
{%- endfor %}
{% for name, fee in fees | dictsort -%}
  {{ name }} costs £{{ fee }}.
{% endfor %}

With two props and two fees, this renders 1 of 2: clubs, 2 of 2: rings, then clubs costs £20. and rings costs £30.. With no props, the first loop renders No props.

VariableGives
loop.index, loop.index0the position, counting from 1 or from 0
loop.revindex, loop.revindex0the items left, counting down to 1 or to 0
loop.first, loop.lasttrue on the first, or the last, time round
loop.lengththe number of items

Variables and scope#

{% set %} gives a value a name. Its block form, {% set name %}...{% endset %}, captures what the block renders. What each part of a page can see of its variables depends on how that part was reached:

What each way of reusing templates can see of the page's variables: an include, a macro in the same file, an imported macro, a parent reached through extends, and a for loop. What each way of reusing templates can see of the page's variables, which include the data the route passed and anything set at the top of the page. An included template sees them all. A macro written in the same file sees them. A macro imported from another file sees only its arguments and the globals, unless it is imported with context. A parent template reached through extends sees the child's top-level set, so a page can set its title for the layout. A for loop sees them, and a set inside the loop changes a variable that already exists outside it, but a variable first set inside the loop is gone when the loop ends. the page's variables the route's data, and top-level set {% include %} sees them all a macro in the same file sees them an imported macro only its arguments and globals, unless imported with context {% extends %} the parent's blocks see the child's top-level set {% for %} set changes existing variables; new ones end with the loop
Figure What each way of reusing templates can see of the page's variables: an include, a macro in the same file, an imported macro, a parent reached through extends, and a for loop.#
As text

What each way of reusing templates can see of the page's variables, which include the data the route passed and anything set at the top of the page. An included template sees them all. A macro written in the same file sees them. A macro imported from another file sees only its arguments and the globals, unless it is imported with context. A parent template reached through extends sees the child's top-level set, so a page can set its title for the layout. A for loop sees them, and a set inside the loop changes a variable that already exists outside it, but a variable first set inside the loop is gone when the loop ends.

depth/nunjucks/views/service/scope.njkscope
{% set total = 0 %}
{% for fee in fees %}
  {% set total = total + fee %}
  {% set lastFee = fee %}
{% endfor %}
Total: {{ total }}. Last fee: [{{ lastFee }}]

With fees of 20 and 30, this renders Total: 50. Last fee: []. The set inside the loop changed total, which existed before the loop. But lastFee began inside the loop, and ended with it.

depth/nunjucks/views/service/reuse.njkreuse
{% set name = "Ada" %}
{% from "macros/greet.njk" import greet %}
{% from "macros/greet.njk" import greet as greetWithContext with context %}
1. {% include "partials/greeting.njk" %}
2. {{ greet() }}
3. {{ greetWithContext() }}

This renders 1. Hello, Ada, 2. Hello, and 3. Hello, Ada. The include sees name. The imported macro does not, until it is imported with context.

Gotcha
An imported macro cannot see your page. The service imports GOV.UK Frontend's macros without context, so everything they show comes through their options object. Pass values in, or import with context.

Macros and caller#

A macro is Nunjucks's component: a named piece of template with arguments. An argument can have a default, and a call can name its arguments, as box("Before you start", tone="warning") does. A macro called with {% call %} gets the content between the tags as caller(), the way a React component gets children:

React (JSX)you know this
function Box({ title, tone = 'info', children }) {
  return (
    <div className={`app-box app-box--${tone}`}>
      <h2 className="govuk-heading-m">{title}</h2>
      {children}
    </div>
  );
}

<Box title="Before you start" tone="warning">
  <p>You need your fire safety certificate number.</p>
</Box>
Nunjucksdepth/nunjucks/views/shared/macros/boxes.njkbox
{% macro box(title, tone="info") %}
<div class="app-box app-box--{{ tone }}">
  <h2 class="govuk-heading-m">{{ title }}</h2>
  {% if caller %}{{ caller() }}{% endif %}
</div>
{% endmacro %}
depth/nunjucks/views/service/callers.njkcallers
{% from "macros/boxes.njk" import box %}
{% call box("Before you start", tone="warning") %}
  <p>You need your fire safety certificate number.</p>
{% endcall %}
{{ box("Nothing inside") }}

A macro's output is marked safe, so its HTML is not escaped when you print it. The values printed inside the macro are escaped as usual.

Inheritance and super()#

A page extends one parent, and fills the parent's blocks. {{ super() }} inside a block prints what the parent had there, so a page can add to a block instead of replacing it:

depth/nunjucks/views/shared/base.njkbase
<title>{% block title %}{{ serviceName }}{% endblock %}</title>
<main>{% block content %}<p>No content yet.</p>{% endblock %}</main>
depth/nunjucks/views/service/page.njkpage
{% extends "base.njk" %}
{% block title %}Your licence – {{ super() }}{% endblock %}
{% block content %}<p>Licence {{ ref }}</p>{% endblock %}

Rendered with a reference, the page's title is "Your licence – Apply for a juggling licence". The parent comes from the shared folder, found by the search in the figure above.

Globals and filters of your own#

An environment takes new globals and filters from JavaScript. A global is a value or a function that every template can use, imported macros included. A filter is a function that gets the value before the | first, then any arguments. "Write it" below adds one.

depth/nunjucks/env.tsdepth/nunjucks/env.jsglobal
// a global reaches every template, and every macro
env.addGlobal(
  'serviceName',
  'Apply for a juggling licence',
);
// a global reaches every template, and every macro
env.addGlobal(
  'serviceName',
  'Apply for a juggling licence',
);

Escaping, exactly#

With autoescaping on, every output is escaped unless the value is marked safe. These five lines show each case:

depth/nunjucks/views/service/escaping.njkescaping
1. {{ comment }}
2. {{ comment | escape | nl2br | safe }}
{% set message %}<b>{{ name }}</b>{% endset %}
3. {{ message }}
4. {{ message | safe }}
5. {{ tag }}
depth/nunjucks/env.tsdepth/nunjucks/env.jssafe-string
// HTML built in JavaScript is escaped like any value,
// unless you mark it safe
export const approvedTag = new nunjucks.runtime.SafeString(
  '<strong class="govuk-tag">Approved</strong>',
);
// HTML built in JavaScript is escaped like any value,
// unless you mark it safe
export const approvedTag = new nunjucks.runtime.SafeString(
  '<strong class="govuk-tag">Approved</strong>',
);

Given a comment of <i>one</i> and a line break, and a name of <Ada>, the lines render:

  1. &lt;i&gt;one&lt;/i&gt;: the value is escaped.
  2. &lt;i&gt;one&lt;/i&gt;<br />: escaped first, then only the line break becomes a tag.
  3. &lt;b&gt;&amp;lt;Ada&amp;gt;&lt;/b&gt;: a set block is escaped again when printed, so its own tags show, and the name is escaped twice.
  4. <b>&lt;Ada&gt;</b>: the block marked safe. Its own HTML stays, and the name is escaped once.
  5. <strong class="govuk-tag">Approved</strong>: HTML built in JavaScript and marked safe there.
Gotcha
A set block is not safe until you say so. Pass it to a macro's html option, which marks it safe, or print it with | safe. Never mark anything safe that contains a person's input you have not escaped.

When a template fails#

MessageWhat causes it
template not found: nope.njkno search folder has that name: check the folders and the spelling
filter not found: nopea misspelt filter, or a custom one not added to this environment
Unable to call nope, which is undefined or falseya macro that was not imported, or a misspelt global or function
unexpected end of filea tag opened and never closed, such as a for with no endfor
unknown block tag: endforan end tag that does not match the tag it closes; the message gives the line and column
attempted to output null or undefined valuewith throwOnUndefined on, a value that does not exist

Read it: the service's layout#

Every page in the service extends this layout. You have seen parts of it. Here it is whole.

views/layout.njk
{# The layout every page extends: GOV.UK Frontend's page template plus the service's own
   pieces. Used by the Express and Fastify apps. Quoted in chapters 05, 06 and 08. #}
{% extends "govuk/template.njk" %}
{% from "govuk/components/back-link/macro.njk" import govukBackLink %}
{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}

{% block pageTitle %}
  {{- "Error: " if errorList }}{{ title }} – {{ serviceName }} – GOV.UK
{%- endblock %}

{% block head %}
  <link rel="stylesheet" href="/govuk/govuk-frontend.min.css">
{% endblock %}

{% block beforeContent %}
  {% if backLink %}
    {{ govukBackLink({ text: "Back", href: backLink }) }}
  {% endif %}
{% endblock %}

{% block content %}
  <div class="govuk-grid-row">
    <div class="govuk-grid-column-two-thirds">
      {% if errorList %}
        {{ govukErrorSummary({
          titleText: "There is a problem",
          errorList: errorList
        }) }}
      {% endif %}
      {% block page %}{% endblock %}
    </div>
  </div>
{% endblock %}

{% block bodyEnd %}
  <script type="module" src="/govuk/govuk-frontend.min.js"></script>
  <script type="module" nonce="{{ cspNonce }}">
    import { initAll } from "/govuk/govuk-frontend.min.js"
    initAll()
  </script>
{% endblock %}
  1. The parent: GOV.UK Frontend's page template, found in the first search folder.
  2. Imports one macro from another file. It sees only its arguments and the globals.
  3. Fills the parent's pageTitle block, which becomes the page's <title>.
  4. An inline if with no else: "Error: " only when there are errors. title is set at the top of each page, and reaches this block through extends. The minus trims the whitespace before the tag.
  5. The minus trims the line break before the tag, so the title has no stray spaces.
  6. The parent's head block, where the service adds its stylesheet.
  7. A route passes backLink only when the page has somewhere to go back to.
  8. A GOV.UK Frontend macro: one options object in, its HTML out.
  9. A new block, inside the parent's content block, for each page to fill.
  10. The script's nonce, printed into an attribute and escaped like any output. The service sets cspNonce for each request.

Write it: a date filter and an answer macro#

The task: a summary of answers in which an empty answer says "Not provided", and a date of birth shows in GOV.UK's style, such as 4 June 2017, with no comma.

1. The filter. Add it to the environment. It gets the value from the left of the |:

depth/nunjucks/env.tsdepth/nunjucks/env.jsfilter
// a filter gets the value before the | first, then its arguments;
// GOV.UK style writes dates as 4 June 2017
env.addFilter('govukDate', (iso: string) => {
  const date = new Date(`${iso}T00:00:00Z`);
  if (Number.isNaN(date.getTime())) return iso;
  return new Intl.DateTimeFormat('en-GB', {
    day: 'numeric',
    month: 'long',
    year: 'numeric',
    timeZone: 'UTC',
  }).format(date);
});
// a filter gets the value before the | first, then its arguments;
// GOV.UK style writes dates as 4 June 2017
env.addFilter('govukDate', (iso) => {
  const date = new Date(`${iso}T00:00:00Z`);
  if (Number.isNaN(date.getTime())) return iso;
  return new Intl.DateTimeFormat('en-GB', {
    day: 'numeric',
    month: 'long',
    year: 'numeric',
    timeZone: 'UTC',
  }).format(date);
});

2. The macro. The minus signs inside its tags keep its output on one line:

depth/nunjucks/views/shared/macros/answers.njkanswer
{% macro answer(value, empty="Not provided") -%}
  {%- if value -%}
    {{ value }}
  {%- else -%}
    <span class="app-not-provided">{{ empty }}</span>
  {%- endif -%}
{%- endmacro %}

3. Use both. A filter's result can be a macro's argument:

depth/nunjucks/views/service/summary.njksummary
{% from "macros/answers.njk" import answer %}
<dl class="govuk-summary-list">
  {% for row in rows %}
    <div class="govuk-summary-list__row">
      <dt class="govuk-summary-list__key">{{ row.label }}</dt>
      <dd class="govuk-summary-list__value">{{ answer(row.value) }}</dd>
    </div>
  {% endfor %}
  <div class="govuk-summary-list__row">
    <dt class="govuk-summary-list__key">Date of birth</dt>
    <dd class="govuk-summary-list__value">{{ answer(dateOfBirth | govukDate) }}</dd>
  </div>
</dl>

The test renders it with an empty email and a date of birth of 1990-05-12. The email shows "Not provided", the date shows "12 May 1990", and a name with angle brackets is escaped inside the macro.

On the job#
Find it
When a template prints nothing where you expect a value, check the name first: Nunjucks prints nothing for anything undefined. Search the environment's set-up for addFilter and addGlobal to find the service's own filters and globals.
Add it
Turn on throwOnUndefined in tests, so that a misspelt variable fails. Give each repeated piece of markup a macro with arguments, and add filters in the environment's set-up.

GOV.UK Frontend#

Part III · The views · Chapter 09·4 min read

GOV.UK Frontend gives you the page template, the components as macros, and the CSS, fonts and JavaScript behind them. This chapter wires all of it into a service, for version 6.5.0.

The anatomy of a question page: each part comes from the page template or from one component macro. A sketch of a question page with an error, with each part labelled with what renders it. At the top, the header and the service navigation showing the service name, from the page template. Below, a Back link from govukBackLink in the beforeContent block. Then the error summary, "There is a problem", with a link reading "Enter your full name", from govukErrorSummary. Then the question, "What is your full name?", which is the input's label and the page heading at once. Under it the error message in red, then the text input, both from govukInput. Then the green Continue button from govukButton. The footer comes from the template too. Service name Apply for a juggling licence ‹ Back There is a problem Enter your full name What is your full name? Enter your full name Continue Footer header and service navigation: govuk/template.njk govukBackLink govukErrorSummary the label is the heading: isPageHeading: true govukInput with errorMessage govukButton footer: govuk/template.njk
Figure The anatomy of a question page: each part comes from the page template or from one component macro.#
As text

A sketch of a question page with an error, with each part labelled with what renders it. At the top, the header and the service navigation showing the service name, from the page template. Below, a Back link from govukBackLink in the beforeContent block. Then the error summary, "There is a problem", with a link reading "Enter your full name", from govukErrorSummary. Then the question, "What is your full name?", which is the input's label and the page heading at once. Under it the error message in red, then the text input, both from govukInput. Then the green Continue button from govukButton. The footer comes from the template too.

Where the files live#

Everything comes from GOV.UK Frontend, the govuk-frontend package. Nunjucks needs its dist folder, so a template can say govuk/template.njk. The browser needs the fonts and images at /assets, because the compiled CSS asks for them there.

Which URL serves which folder: fonts and images at /assets, the CSS and JavaScript at a URL you choose, and the templates never served at all. Which URL serves which folder of the govuk-frontend package. The URL /assets serves node_modules/govuk-frontend/dist/govuk/assets, which holds the fonts and images; the compiled CSS asks for them there, so this path is fixed. A URL you choose, /govuk in the example, serves node_modules/govuk-frontend/dist/govuk, which holds govuk-frontend.min.css and govuk-frontend.min.js. The templates, such as govuk/template.njk, are found through Nunjucks' search path, node_modules/govuk-frontend/dist, and are never served to the browser. URL THE BROWSER ASKS FOR FOLDER IN node_modules/govuk-frontend /assets/... fixed: the CSS asks for it dist/govuk/assets/ fonts, images, manifest.json /govuk/... any URL you choose dist/govuk/ govuk-frontend.min.css, govuk-frontend.min.js govuk/template.njk a Nunjucks name, not a URL dist/ + govuk/template.njk found through the view folders; never served
Figure Which URL serves which folder: fonts and images at /assets, the CSS and JavaScript at a URL you choose, and the templates never served at all.#
As text

Which URL serves which folder of the govuk-frontend package. The URL /assets serves node_modules/govuk-frontend/dist/govuk/assets, which holds the fonts and images; the compiled CSS asks for them there, so this path is fixed. A URL you choose, /govuk in the example, serves node_modules/govuk-frontend/dist/govuk, which holds govuk-frontend.min.css and govuk-frontend.min.js. The templates, such as govuk/template.njk, are found through Nunjucks' search path, node_modules/govuk-frontend/dist, and are never served to the browser.

shared/paths.tsshared/paths.jspaths
export const paths = {
  // lets a template say {% extends "govuk/template.njk" %}
  govukViews: frontend,
  // the fonts and images GOV.UK Frontend's CSS asks for at /assets
  govukAssets: join(frontend, 'govuk', 'assets'),
  // govuk-frontend.min.css and govuk-frontend.min.js
  govukDist: join(frontend, 'govuk'),
  views: join(root, 'views'),
};
export const paths = {
  // lets a template say {% extends "govuk/template.njk" %}
  govukViews: frontend,
  // the fonts and images GOV.UK Frontend's CSS asks for at /assets
  govukAssets: join(frontend, 'govuk', 'assets'),
  // govuk-frontend.min.css and govuk-frontend.min.js
  govukDist: join(frontend, 'govuk'),
  views: join(root, 'views'),
};
ASP.NET Core Like app.UseStaticFiles() with an extra file provider for a package folder.
Expressexpress/app.tsexpress/app.jsassets
app.use('/assets', express.static(paths.govukAssets));
app.use('/govuk', express.static(paths.govukDist));
app.use('/assets', express.static(paths.govukAssets));
app.use('/govuk', express.static(paths.govukDist));
Fastifyfastify/app.tsfastify/app.jsassets
await app.register(fastifyStatic, {
  root: paths.govukAssets,
  prefix: '/assets/',
});
await app.register(fastifyStatic, {
  root: paths.govukDist,
  prefix: '/govuk/',
  decorateReply: false,
});
await app.register(fastifyStatic, {
  root: paths.govukAssets,
  prefix: '/assets/',
});
await app.register(fastifyStatic, {
  root: paths.govukDist,
  prefix: '/govuk/',
  decorateReply: false,
});

The page template#

Your layout extends govuk/template.njk and fills its blocks. The stylesheet goes in head, and the JavaScript goes in bodyEnd:

views/layout.njkstyles
{% block head %}
  <link rel="stylesheet" href="/govuk/govuk-frontend.min.css">
{% endblock %}
views/layout.njkscripts
{% block bodyEnd %}
  <script type="module" src="/govuk/govuk-frontend.min.js"></script>
  <script type="module" nonce="{{ cspNonce }}">
    import { initAll } from "/govuk/govuk-frontend.min.js"
    initAll()
  </script>
{% endblock %}

The script must be a module. GOV.UK Frontend's documentation loads govuk-frontend.min.js with type="module", then calls initAll(), which starts every component on the page.

The inline script and your CSP#

The page template starts with a one-line inline script that adds the js-enabled and govuk-frontend-supported classes. A Content Security Policy (CSP) blocks inline scripts unless they carry a nonce: a random value that changes on every request. The template adds one when you pass cspNonce, and the module script above uses the same value.

ASP.NET Core Like adding a per-request nonce to your CSP header in middleware.
Expressexpress/app.tsexpress/app.jshelmet
app.use((req, res, next) => {
  res.locals.cspNonce = randomBytes(16).toString('hex');
  next();
});
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        scriptSrc: [
          "'self'",
          (req, res) =>
            `'nonce-${(res as Response).locals.cspNonce}'`,
        ],
      },
    },
  }),
);
app.use((req, res, next) => {
  res.locals.cspNonce = randomBytes(16).toString('hex');
  next();
});
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        scriptSrc: [
          "'self'",
          (req, res) => `'nonce-${res.locals.cspNonce}'`,
        ],
      },
    },
  }),
);
Fastifyfastify/app.tsfastify/app.jshelmet
await app.register(fastifyHelmet, {
  enableCSPNonces: true,
});
await app.register(fastifyHelmet, {
  enableCSPNonces: true,
});

The Fastify version hands reply.cspNonce.script to every page through reply.locals, in the hook shown in chapter 6.

Gotcha
No nonce, no JavaScript. Turn on a CSP without passing cspNonce and the browser blocks the template's inline script. Nothing fails on the server; the components just stop enhancing. GOV.UK Frontend's documentation also gives a hash you can allow instead.

Using a component#

Import a component's macro, then call it with one object of options. Copy the call from the Nunjucks tab of the component's page on the Design System site, then change the options:

views/pages/fire.njkradios
{{ govukRadios({
  name: "fire",
  idPrefix: "fire",
  fieldset: {
    legend: {
      text: title,
      classes: "govuk-fieldset__legend--l",
      isPageHeading: true
    }
  },
  hint: { text: "This includes torches, clubs and poi" },
  classes: "govuk-radios--inline",
  items: [
    { value: "yes", text: "Yes" },
    { value: "no", text: "No" }
  ],
  value: values.fire,
  errorMessage: fieldErrors.fire
}) }}

Three options do most of the work. value decides which answer shows as chosen. errorMessage shows the field's error. isPageHeading makes the label or legend the page's h1.

What every component macro shares#

Every GOV.UK Frontend macro takes one object of options, and most of them share these names. Knowing them lets you read any macro call, and write your own:

OptionWhat it does
text, htmlthe content: text is escaped, html is not, and html wins when both are set; some pairs have a prefix, such as titleText and titleHtml
classesextra classes, added after the component's own
attributesextra HTML attributes, as an object; each template prints them with GOV.UK Frontend's govukAttributes macro, which escapes the values
id, namethe element's id and the form field's name; radios and checkboxes build their items' ids from idPrefix
itemsthe choices of radios, checkboxes and selects; a summary list calls its rows rows
errorMessage{ text } to show an error, or false for none
label, hint, fieldset.legendobjects with their own text, html and classes; isPageHeading: true makes a label or legend the page's heading
content between call tagssome components, such as inset text, render it with caller()

A component of your own#

When the Design System has nothing for what you need, write a component in the same shape, so that the rest of the team can read it. GOV.UK Frontend splits each component into a macro and a template. The macro includes the template, which reads params:

views/components/licence-card/macro.njkmacro
{% macro appLicenceCard(params) %}
  {%- include "./template.njk" -%}
{% endmacro %}
views/components/licence-card/template.njktemplate
{% from "govuk/macros/attributes.njk" import govukAttributes -%}
<div class="app-licence-card {%- if params.classes %} {{ params.classes }}{% endif %}"
  {{- govukAttributes(params.attributes) }}>
  <h2 class="govuk-heading-s">
    {{ params.titleHtml | safe if params.titleHtml else params.titleText }}
  </h2>
  <p class="govuk-body">Reference: {{ params.reference }}</p>
  {% if caller %}{{ caller() }}{% endif %}
</div>

Calling it looks like calling any GOV.UK Frontend component:

views/components/licence-card/example.njkcall
{% from "components/licence-card/macro.njk" import appLicenceCard %}
{% call appLicenceCard({
  titleText: "Juggling licence",
  reference: "JL-0042",
  classes: "app-licence-card--active",
  attributes: { "data-licence": "JL-0042" }
}) %}
  <p class="govuk-body">Valid until 1 October 2026.</p>
{% endcall %}

The example app's test renders it, and checks that titleText is escaped, titleHtml is not and wins, the classes and attributes arrive, and the content between the call tags appears.

Note
The example loads the compiled CSS. If you build your own Sass, version 6 needs Dart Sass 1.79 or later, and @use instead of @import: @use "node_modules/govuk-frontend/dist/govuk" as *;
On the job#
Find it
Check the govuk-frontend version in package.json. Version 6 code uses Sass @use; version 5 code uses @import. Then find where /assets is served.
Add it
Serve dist/govuk/assets at /assets, load the CSS and the module script, and pass cspNonce to every page.

Journeys: one thing per page#

Part IV · Patterns and standards · Chapter 10·3 min read

A GOV.UK form is a journey of short pages, each asking one thing, ending with check answers and a confirmation. This chapter shows the journey as data, then as code.

The juggling-licence journey: one question per page, and the answer about fire decides the routeyesnonameemailfirefire-certificatecheck-answersconfirmation
Figure The juggling-licence journey: one question per page, and the answer about fire decides the route#
As text

The journey starts at name.

  1. name, then email.
  2. email, then fire.
  3. fire asks a question. Yes: fire-certificate. No: check-answers.
  4. fire-certificate, then check-answers.
  5. check-answers, then confirmation.
  6. confirmation is the final page.

The Design System's question pages pattern starts from one question per page (Design System: question pages). The question is the page heading, set as the input's label or the fieldset's legend. Every question page has a Back link at the top, and its button says Continue, not Next.

The journey as data#

The example keeps the whole journey in one object: each page's fields, and a function that picks the next page from the answers so far.

shared/journey.tsshared/journey.jsjourney
export const questions: Record<QuestionPage, Question> = {
  name: { fields: ['fullName'], next: () => 'email' },
  email: { fields: ['email'], next: () => 'fire' },
  fire: {
    fields: ['fire'],
    next: (answers) =>
      answers.fire?.fire === 'yes'
        ? 'fire-certificate'
        : 'check-answers',
  },
  'fire-certificate': {
    fields: ['certificate'],
    next: () => 'check-answers',
  },
};
export const questions = {
  name: { fields: ['fullName'], next: () => 'email' },
  email: { fields: ['email'], next: () => 'fire' },
  fire: {
    fields: ['fire'],
    next: (answers) =>
      answers.fire?.fire === 'yes'
        ? 'fire-certificate'
        : 'check-answers',
  },
  'fire-certificate': {
    fields: ['certificate'],
    next: () => 'check-answers',
  },
};

From that object, route() works out which pages the user can reach. The GET route in chapter 3 uses it to stop anyone skipping ahead by typing an address.

shared/journey.tsshared/journey.jsroute
// The pages a user can reach with their answers so far, in
// order. It stops at the first page they have not answered,
// or at check-answers once every page on the route is done.
export function route(answers: Answers): string[] {
  const pages: string[] = [];
  let page: string = firstPage;
  while (isQuestion(page)) {
    pages.push(page);
    if (!answers[page]) return pages;
    page = questions[page].next(answers);
  }
  return [...pages, page];
}
// The pages a user can reach with their answers so far, in
// order. It stops at the first page they have not answered,
// or at check-answers once every page on the route is done.
export function route(answers) {
  const pages = [];
  let page = firstPage;
  while (isQuestion(page)) {
    pages.push(page);
    if (!answers[page]) return pages;
    page = questions[page].next(answers);
  }
  return [...pages, page];
}

Check answers and confirmation#

Before the last step, show every answer on one check answers page, with a Change link for each (Design System: check answers). After a change, Continue returns the user to check answers, not through the rest of the journey. The example's nextPage() does that, unless the new answer opens a page that has not been answered yet.

Changing an answer from check answers: Continue returns there, unless the new answer opens a page not yet answeredBrowserServerSessionGET /fire?change=true1the fire page; Back goes to check answers2POST /fire?change=true, yes3save: fire is yes4fire-certificate is not answered yet5302: go to /fire-certificate6POST /fire-certificate, FS-1234567302: back to /check-answers8
Figure Changing an answer from check answers: Continue returns there, unless the new answer opens a page not yet answered#
As text
  1. Browser to Server: GET /fire?change=true
  2. Server to Browser: the fire page; Back goes to check answers
  3. Browser to Server: POST /fire?change=true, yes
  4. Server to Session: save: fire is yes
  5. Server: fire-certificate is not answered yet
  6. Server to Browser: 302: go to /fire-certificate
  7. Browser to Server: POST /fire-certificate, FS-123456
  8. Server to Browser: 302: back to /check-answers
ASP.NET Core Like a Razor Page whose OnPost sends the application, then redirects.
Expressexpress/routes.tsexpress/routes.jscheck-answers
router.get('/check-answers', (req, res) => {
  const answers = req.session.answers ?? {};
  const pages = route(answers);
  if (pages.at(-1) !== 'check-answers') {
    return res.redirect(`/${pages.at(-1)}`);
  }
  res.render('pages/check-answers', {
    rows: summaryRows(answers),
    backLink: backLink('check-answers', answers, false),
  });
});

router.post('/check-answers', (req, res) => {
  const answers = req.session.answers ?? {};
  if (route(answers).at(-1) !== 'check-answers') {
    return res.redirect('/');
  }
  // a real service sends the application to its API here
  req.session.answers = undefined;
  req.session.reference = newReference();
  res.redirect('/confirmation');
});
router.get('/check-answers', (req, res) => {
  const answers = req.session.answers ?? {};
  const pages = route(answers);
  if (pages.at(-1) !== 'check-answers') {
    return res.redirect(`/${pages.at(-1)}`);
  }
  res.render('pages/check-answers', {
    rows: summaryRows(answers),
    backLink: backLink('check-answers', answers, false),
  });
});

router.post('/check-answers', (req, res) => {
  const answers = req.session.answers ?? {};
  if (route(answers).at(-1) !== 'check-answers') {
    return res.redirect('/');
  }
  // a real service sends the application to its API here
  req.session.answers = undefined;
  req.session.reference = newReference();
  res.redirect('/confirmation');
});
Fastifyfastify/routes.tsfastify/routes.jscheck-answers
app.get('/check-answers', async (request, reply) => {
  const answers = request.session.answers ?? {};
  const pages = route(answers);
  if (pages.at(-1) !== 'check-answers') {
    return reply.redirect(`/${pages.at(-1)}`);
  }
  return reply.view('pages/check-answers', {
    rows: summaryRows(answers),
    backLink: backLink('check-answers', answers, false),
  });
});

app.post('/check-answers', async (request, reply) => {
  const answers = request.session.answers ?? {};
  if (route(answers).at(-1) !== 'check-answers') {
    return reply.redirect('/');
  }
  // a real service sends the application to its API here
  request.session.answers = undefined;
  request.session.reference = newReference();
  return reply.redirect('/confirmation');
});
app.get('/check-answers', async (request, reply) => {
  const answers = request.session.answers ?? {};
  const pages = route(answers);
  if (pages.at(-1) !== 'check-answers') {
    return reply.redirect(`/${pages.at(-1)}`);
  }
  return reply.view('pages/check-answers', {
    rows: summaryRows(answers),
    backLink: backLink('check-answers', answers, false),
  });
});

app.post('/check-answers', async (request, reply) => {
  const answers = request.session.answers ?? {};
  if (route(answers).at(-1) !== 'check-answers') {
    return reply.redirect('/');
  }
  // a real service sends the application to its API here
  request.session.answers = undefined;
  request.session.reference = newReference();
  return reply.redirect('/confirmation');
});

The confirmation page shows the reference number and what happens next (Design System: confirmation pages). Let users come back to it; the pattern asks you to allow that whenever possible.

Note
WCAG 2.2 added WCAG 2.2 3.3.7 Redundant Entry: do not make people enter the same information twice in one process. Keep answers in the session and show them back.
On the job#
Find it
Find the journey's map: a next-page function or table, like questions here, or a CASA plan. It answers the question "why did Continue send me there?".
Add it
Write the journey down as data first: pages, fields and a next-page rule. Then guard every GET so users cannot skip ahead.

Validation and errors, the GOV.UK way#

Part IV · Patterns and standards · Chapter 11·3 min read

GOV.UK shows an error one way: a summary at the top, a message beside the field, and "Error: " at the start of the title. This chapter builds that on the server.

The error loop: the server validates, then shows the same page again, with the answer kept and the error shownBrowserServerPOST /name, empty1validate: one error2200: same page, title starts Error:3focus moves to the error summary4the user follows the link to the field5POST /name, Ada Lovelace6validate: no errors7302: go to /email8
Figure The error loop: the server validates, then shows the same page again, with the answer kept and the error shown#
As text
  1. Browser to Server: POST /name, empty
  2. Server: validate: one error
  3. Server to Browser: 200: same page, title starts Error:
  4. Browser: focus moves to the error summary
  5. Browser: the user follows the link to the field
  6. Browser to Server: POST /name, Ada Lovelace
  7. Server: validate: no errors
  8. Server to Browser: 302: go to /email

The rules come from the Design System (Design System: validation):

  • Always validate on the server, even if you also check in the browser.
  • Validate when the user presses Continue, not when they leave a field.
  • Show the page again with everything the user typed, right or wrong.
  • Turn off the browser's own validation with novalidate, and do not add required to inputs.
  • Start the page title with "Error: ", so screen readers announce it first.

Each summary item links to its field, and the field shows the same words (Design System: error summary). For radios, link to the first radio; for a date, link to the first field with an error. GOV.UK Frontend's JavaScript moves focus to the summary when the page loads. Together these meet WCAG 2.2 3.3.1 Error Identification and WCAG 2.2 3.3.3 Error Suggestion.

One validator for both frameworks#

The example keeps its rules in plain functions that return a field and a message. One helper turns them into what the templates need: a list for the summary, and one message per field.

shared/validators.tsshared/validators.jserror-view
// What a template needs when there are errors: a list for the
// error summary, linking to each field, and one message per
// field for the field itself.
export function errorView(errors: FieldError[]) {
  return {
    errorList: errors.map((e) => ({
      text: e.text,
      href: `#${e.field}`,
    })),
    fieldErrors: Object.fromEntries(
      errors.map((e) => [e.field, { text: e.text }]),
    ),
  };
}
// What a template needs when there are errors: a list for the
// error summary, linking to each field, and one message per
// field for the field itself.
export function errorView(errors) {
  return {
    errorList: errors.map((e) => ({
      text: e.text,
      href: `#${e.field}`,
    })),
    fieldErrors: Object.fromEntries(
      errors.map((e) => [e.field, { text: e.text }]),
    ),
  };
}

The layout adds the title prefix and the summary, so each page passes only its own field's error:

views/layout.njktitle
{% block pageTitle %}
  {{- "Error: " if errorList }}{{ title }} – {{ serviceName }} – GOV.UK
{%- endblock %}
views/layout.njkerror-summary
{% if errorList %}
  {{ govukErrorSummary({
    titleText: "There is a problem",
    errorList: errorList
  }) }}
{% endif %}
Razoryou know this
<label asp-for="FullName">What is your full name?</label>
<span asp-validation-for="FullName"></span>
<input asp-for="FullName" />
Nunjucksviews/pages/name.njkfield
{{ govukInput({
  label: {
    text: title,
    classes: "govuk-label--l",
    isPageHeading: true
  },
  id: "fullName",
  name: "fullName",
  value: values.fullName,
  autocomplete: "name",
  spellcheck: false,
  errorMessage: fieldErrors.fullName
}) }}

Where the rule lives#

Where the rule lives decides what the user sees: a validator in the handler, or CASA's field validators, give the GOV.UK pattern; a bare Fastify schema does not. Where a validation rule can live, and what the user sees when a field is empty. In Express, the POST handler calls a validator function and the user gets the GOV.UK error summary. In Fastify, the same validator function in the handler gives the same result. In Fastify with a JSON Schema on the route, Fastify itself answers 400 with a developer message such as "body must have required property", which is not the GOV.UK pattern, unless the route sets attachValidation and renders the page itself. In CASA, validators attached to each field give the GOV.UK error summary without extra code. FRAMEWORK WHERE THE RULE LIVES WHEN A FIELD IS EMPTY Express validator function, called in the handler GOV.UK error summary Fastify the same function, in the handler GOV.UK error summary Fastify JSON Schema on the route 400, a message for developers CASA validators on each field GOV.UK error summary, built in unless attachValidation hands the error to your handler
Figure Where the rule lives decides what the user sees: a validator in the handler, or CASA's field validators, give the GOV.UK pattern; a bare Fastify schema does not.#
As text

Where a validation rule can live, and what the user sees when a field is empty. In Express, the POST handler calls a validator function and the user gets the GOV.UK error summary. In Fastify, the same validator function in the handler gives the same result. In Fastify with a JSON Schema on the route, Fastify itself answers 400 with a developer message such as "body must have required property", which is not the GOV.UK pattern, unless the route sets attachValidation and renders the page itself. In CASA, validators attached to each field give the GOV.UK error summary without extra code.

Gotcha
Fastify's schema validation is not the GOV.UK pattern. A JSON Schema on the route makes Fastify answer 400 itself, with a message for developers such as "body must have required property 'name'". For GOV.UK pages, validate in the handler, as the example does. If you want a schema as well, set attachValidation: true. Fastify then puts the error on request.validationError and lets your handler render the page.
On the job#
Find it
Find where errors are built: a list for govukErrorSummary, and a message for each field's errorMessage. Check that the title starts with "Error: " when there are errors.
Add it
Validate on the server when the user presses Continue, keep what they typed, link every summary item to its field, and put novalidate on every form.

The standards you will be assessed on#

Part IV · Patterns and standards · Chapter 12·3 min read

A government service is assessed against the Service Standard, and must meet WCAG 2.2 AA. This chapter says which parts land on developers, and when.

The phases of a service: discovery, alpha, private beta, public beta and live, with a beta assessment before public beta and another assessment before live. The phases of a government service, left to right: discovery, alpha, private beta, public beta and live. The Service Manual tells teams to book an assessment during alpha, to pass a beta assessment to move from private beta into public beta, and to book another assessment when ready to go live. What developers do: in discovery, little code; in alpha, prototypes; in private beta, the real service for a limited group of users; in public beta, the service open to everyone, with a cookies page published by then; in live, running and improving it. A transactional service for central government must be assessed, by a cross-government panel if it will handle more than 100,000 transactions a year or be used by civil servants in more than one organisation, otherwise by a departmental panel. assessment beta assessment live assessment discovery alpha private beta public beta live little code: research prototypes the real service, a limited group open to everyone; cookies page by now run and improve A transactional service for central government must be assessed: by a cross-government panel if it will handle more than 100,000 transactions a year or be used by civil servants in more than one organisation, otherwise by a panel from its own department.
Figure The phases of a service: discovery, alpha, private beta, public beta and live, with a beta assessment before public beta and another assessment before live.#
As text

The phases of a government service, left to right: discovery, alpha, private beta, public beta and live. The Service Manual tells teams to book an assessment during alpha, to pass a beta assessment to move from private beta into public beta, and to book another assessment when ready to go live. What developers do: in discovery, little code; in alpha, prototypes; in private beta, the real service for a limited group of users; in public beta, the service open to everyone, with a cookies page published by then; in live, running and improving it. A transactional service for central government must be assessed, by a cross-government panel if it will handle more than 100,000 transactions a year or be used by civil servants in more than one organisation, otherwise by a departmental panel.

A transactional service for central government must be assessed (Service Manual: check if you need a service assessment). Each assessment checks the service against the Service Standard's 14 points (Service Manual: agile delivery phases explains each phase).

The points that land on developers#

PointWhat it asksWhat you do
5: make sure everyone can use the servicemeet WCAG 2.2 AA, and work with assistive technologyuse the Design System's components, test with a keyboard and a screen reader
9: create a secure serviceprotect users' data and privacysessions, CSRF tokens, security headers, no secrets in code
11: choose the right tools and technologytools you can justifyexplain why you chose each framework and package
12: make new source code openpublish the codekeep secrets and configuration out of the repository
13: use common standards, components and patternsreuse what existsGOV.UK Frontend's macros and the Design System's patterns
14: operate a reliable servicekeep it running and recoverablehealth checks, monitoring and clear error pages

Read the points themselves:

Accessibility is a legal duty#

Public sector websites and apps must meet WCAG 2.2 at level AA, and publish an accessibility statement (Accessibility requirements for public sector websites and apps). The Design System's components and patterns do much of the work. You still own the rest: page titles (WCAG 2.2 2.4.2 Page Titled), labels (WCAG 2.2 3.3.2 Labels or Instructions), keyboard access (WCAG 2.2 2.1.1 Keyboard), and testing with assistive technology.

Cookies, and working without JavaScript#

Every service must publish a cookies page by public beta (Design System: cookies page). You do not need consent for cookies the service cannot work without, such as the session cookie. You do need consent before setting any other cookie, which means a cookie banner.

Progressive enhancement, from chapter 2, is a Service Manual rule as well: build every page to work with HTML alone, then enhance it.

On the job#
Find it
Find the service's accessibility statement, its cookies page and its last assessment report. They tell you what the team has already promised.
Add it
Before public beta, publish a cookies page and an accessibility statement, and plan an accessibility audit.

On the job#

Part V · On the job · Chapter 13·5 min read

Two checklists built from every chapter's "On the job" box, and two tables for the code you will inherit.

Decode an existing repository#

  1. Open package.json. express or fastify names the server, the govuk-frontend version names the components, and @dwp/govuk-casa means the journey is configured rather than written by hand.
    Chapter 01: The stack on one page
  2. Pick a question page and find its two routes: a GET that renders the template, and a POST that validates, then renders again or redirects.
    Chapter 02: The request loop
  3. Find where the app is created: express() or Fastify(). Read down from there. In Express, the order of app.use calls is the order a request runs. In Fastify, read each register and addHook.
    Chapter 03: Express and Fastify, from zero
  4. List the routes: search for Router(, app.use( and .get( in Express, and app.register( and prefix in Fastify. Each mount path or prefix is added to the start of every route inside it.
    Chapter 04: Routes, requests and responses
  5. Open the file that builds the app and read it top to bottom: the order of middleware or plugins, then the error handler. Check the Express or Fastify major version in package.json before you trust an example.
    Chapter 05: Structure, errors, logging and tests
  6. Search for express-session or @fastify/session and check which store production uses. Then check that the CSRF check reads the token from the form body.
    Chapter 06: The plumbing
  7. Find the folder list passed to nunjucks.configure() or to @fastify/view. Every template name, such as govuk/template.njk, is looked up in those folders, in order.
    Chapter 07: Nunjucks for JSX and Razor developers
  8. When a template prints nothing where you expect a value, check the name first: Nunjucks prints nothing for anything undefined. Search the environment's set-up for addFilter and addGlobal to find the service's own filters and globals.
    Chapter 08: Nunjucks in depth
  9. Check the govuk-frontend version in package.json. Version 6 code uses Sass @use; version 5 code uses @import. Then find where /assets is served.
    Chapter 09: GOV.UK Frontend
  10. Find the journey's map: a next-page function or table, like questions here, or a CASA plan. It answers the question "why did Continue send me there?".
    Chapter 10: Journeys: one thing per page
  11. Find where errors are built: a list for govukErrorSummary, and a message for each field's errorMessage. Check that the title starts with "Error: " when there are errors.
    Chapter 11: Validation and errors, the GOV.UK way
  12. Find the service's accessibility statement, its cookies page and its last assessment report. They tell you what the team has already promised.
    Chapter 12: The standards you will be assessed on
  13. In a CASA app, open the file that calls configure(). The pages hold the fields and validators, the Plan holds the routes, and the views folder holds the templates.
    Chapter 15: CASA
  14. In a CASA service, start at the file that calls configure(). The pages hold the fields and validators, the plan the routes, and the hooks any custom work. Check which CASA major version package.json has before you trust a template's block names.
    Chapter 16: CASA in depth
The shape most GOV.UK Node repositories share, and what each part tells you. The shape most GOV.UK Node repositories share, with what to read in each part. package.json tells you the framework and the GOV.UK Frontend version. The app or server file holds the plumbing, in order. The routes file holds a GET and a POST for each page. The journey logic says which page comes next, and the validators hold the error messages. In views, layout.njk extends govuk/template.njk, and there is one template per page. Names differ between teams: in a CASA app, the pages, fields and plan sit together in the file that calls configure(). package.json app.ts, server.ts routes.ts journey.ts validators.ts views/layout.njk views/pages/*.njk which framework, which GOV.UK Frontend express or fastify; govuk-frontend 5 or 6 the plumbing, in the order it runs a GET and a POST for each page which page comes next the error messages extends govuk/template.njk one template per question page
Figure The shape most GOV.UK Node repositories share, and what each part tells you.#
As text

The shape most GOV.UK Node repositories share, with what to read in each part. package.json tells you the framework and the GOV.UK Frontend version. The app or server file holds the plumbing, in order. The routes file holds a GET and a POST for each page. The journey logic says which page comes next, and the validators hold the error messages. In views, layout.njk extends govuk/template.njk, and there is one template per page. Names differ between teams: in a CASA app, the pages, fields and plan sit together in the file that calls configure().

Tracing a page, from the address in the browser to the code and data behind itthe address, such as /firethe GET and POST routes that match itthe template the GET rendersthe macros that template callsthe session keys the POST writesthe next-page rule it follows
Figure Tracing a page, from the address in the browser to the code and data behind it#
As text
  1. the address, such as /fire
  2. the GET and POST routes that match it
  3. the template the GET renders
  4. the macros that template calls
  5. the session keys the POST writes
  6. the next-page rule it follows

What package.json tells you#

You seeIt means
expressan Express app: read the app.use calls in order
express 4.xExpress 4: wrapped async handlers and old path syntax; see chapter 5
fastify with @fastify/*a Fastify app: read the register calls and the hooks
fastify 4.xFastify 4: reply.redirect(code, url) and short-hand schemas; see chapter 5
@dwp/govuk-casaa CASA journey: pages, fields and a plan, in the file that calls configure()
@dwp/govuk-casa 9.xCASA 9, on Express 4, with older layout block names; see chapter 16
govuk-frontend 5.xversion 5: Sass @import, and a rebrand option on the header and footer
govuk-frontend 6.xversion 6: Sass @use, and no rebrand option
govuk-prototype-kitthe GOV.UK Prototype Kit: a prototype, not the production service
express-session, @fastify/sessionserver-side sessions: check the production store

Start a new service#

  1. Start from three packages: a server (Express or Fastify), nunjucks and govuk-frontend. The rest of this book adds the plumbing around them.
    Chapter 01: The stack on one page
  2. Give every question page a GET and a POST. Save valid answers in the session, and always redirect after a successful POST.
    Chapter 02: The request loop
  3. Choose one framework per service. CASA needs Express; Fastify gives you typed routes, schemas and scoped plugins.
    Chapter 03: Express and Fastify, from zero
  4. Give each group of pages its own router or plugin at one prefix. Trust the proxy by its addresses, and set path: '/' on every Fastify cookie.
    Chapter 04: Routes, requests and responses
  5. Build the app in a function the tests can call. Add an error handler that shows a GOV.UK page, a health check, and request logging with the cookie redacted.
    Chapter 05: Structure, errors, logging and tests
  6. Add the plumbing in this order: headers, static files, form bodies, session, CSRF, then routes, then the 404 and error handlers.
    Chapter 06: The plumbing
  7. Put a layout.njk that extends govuk/template.njk in your views folder, and make every page extend it.
    Chapter 07: Nunjucks for JSX and Razor developers
  8. Turn on throwOnUndefined in tests, so that a misspelt variable fails. Give each repeated piece of markup a macro with arguments, and add filters in the environment's set-up.
    Chapter 08: Nunjucks in depth
  9. Serve dist/govuk/assets at /assets, load the CSS and the module script, and pass cspNonce to every page.
    Chapter 09: GOV.UK Frontend
  10. Write the journey down as data first: pages, fields and a next-page rule. Then guard every GET so users cannot skip ahead.
    Chapter 10: Journeys: one thing per page
  11. Validate on the server when the user presses Continue, keep what they typed, link every summary item to its field, and put novalidate on every form.
    Chapter 11: Validation and errors, the GOV.UK way
  12. Before public beta, publish a cookies page and an accessibility statement, and plan an accessibility audit.
    Chapter 12: The standards you will be assessed on
  13. Start from configure() with a plan and pages, mount it on an Express app, and add your confirmation page as an ancillary route.
    Chapter 15: CASA
  14. Give every validator a GOV.UK error message, dateObject as objects. Set session.secure from the environment, use a shared session store, and put pages outside the plan on the ancillary router.
    Chapter 16: CASA in depth

Versions you will inherit#

Chapter 5 runs Express 4 and Fastify 4 code beside the current versions, and chapter 16 does the same for CASA 9. This table lists the changes you will meet most.

Older codeWhat changedWhere it bites
Express 4wildcards need a name, /*splat; optional parts use bracesold route paths stop matching
Express 4rejected promises now reach the error handlerold code wraps async handlers by hand
Express 4req.body is undefined without a parser; urlencoded defaults to extended: falsenested form fields
Express 4res.redirect('back') is gone: use req.get('Referrer') || '/'back links
Fastify 4reply.redirect(code, url) became reply.redirect(url, code)every redirect
Fastify 4schemas need type: 'object' and properties; request decorators cannot hold an objectstart-up errors
GOV.UK Frontend 5Sass moved from @import to @use, with Dart Sass 1.79 or lateryour Sass entry file
GOV.UK Frontend 5the header and footer lost their rebrand optionlayout templates
CASA 9CASA 10 moved from Express 4 and GOV.UK Frontend 5 to Express 5 and GOV.UK Frontend 6, and needs Node 22upgrade all three together
CASA 9layout blocks beforeContent, skipLink and header were renamedoverridden blocks stop showing, with no error
CommonJSrequire() became import, and __dirname became import.meta.dirnamethe top of every file

Quick reference#

Part V · On the job · Chapter 14·3 min read

The book on one page to print, a quick reference for each tool, and where to look when the book stops.

A question page
StepWhat the code does
GETrender the template with the saved answer
POSTpick the page's fields, then validate them
errorsrender the same template with errorList and fieldErrors
no errorssave to the session, then redirect with a 302
typed addressredirect to the first page not yet answered
Plumbing, in order
StepExpressFastify
headershelmet@fastify/helmet
filesexpress.static@fastify/static
bodiesexpress.urlencoded()@fastify/formbody
sessionexpress-session@fastify/session
CSRFcsrf-sync@fastify/csrf-protection
Templates
ToWrite
extend the layout{% extends "layout.njk" %}
use a component{% from "govuk/components/input/macro.njk" import govukInput %}
fill a block{% block page %}...{% endblock %}
pass HTML safely{% set x %}...{% endset %}, then html: x
Errors
RuleDetail
whereon the server, when the user presses Continue
answersshown again, as the user typed them
summarytop of the page, one link per field
fieldthe same words, through errorMessage
titlestarts with Error:
formsnovalidate, and no required

Express 5#

You wantWriteTaught in
the appconst app = express()chapter 3
a settingapp.set('trust proxy', ['loopback', '10.0.0.0/8']), app.set('view engine', 'njk')chapter 4
middlewareapp.use(fn), or app.use('/path', fn); call next() to go onchapter 3
a routeapp.get(path, ...handlers), and post, put, delete, all, app.route(path)chapter 4
path syntax/:id, /search{/:term}, /docs/*pathchapter 4
a group of routesRouter({ mergeParams: true }), then app.use('/prefix', router)chapter 4
the requestreq.params, req.query, req.body, req.get(name), req.cookies, req.session, req.ip, req.originalUrlchapter 4
the responseres.status(), res.set(), res.type(), res.send(), res.json(), res.render(), res.redirect([status,] url), res.cookie(), res.localschapter 4
skip to the next routenext('route')chapter 4
errorsthrow, or reject; an error handler (err, req, res, next) added lastchapter 5
not founda last app.use((req, res) => ...)chapter 5
request logsapp.use(pinoHttp()), then req.log.info()chapter 5
startapp.listen(port)chapter 3

Fastify 5#

You wantWriteTaught in
the appFastify({ logger, trustProxy })chapter 3
a pluginawait app.register(plugin, { prefix }); share it with fastify-pluginchapter 5
a routeapp.get(path, [options], handler), or app.route({ method, url, handler })chapter 4
route optionsschema with body, querystring, params and response; attachValidation; preHandler; logLevelchapter 4
path syntax/:id, /search/:term?, /docs/*, /years/:year(^\\d{4}$)chapter 4
a decoratorapp.decorate(), app.decorateReply(), app.decorateRequest(name, null) with an onRequest hookchapter 5
the requestrequest.params, request.query, request.body, request.headers, request.cookies, request.session, request.ip, request.url, request.logchapter 4
the replyreply.code(), reply.header(), reply.type(), return value, reply.view(), reply.redirect(url, [status]), reply.setCookie(), reply.localschapter 4
hooks, in orderonRequest, preParsing, preValidation, preHandler, the handler, preSerialization, onSend, onResponsechapter 3
errorsthrow; app.setErrorHandler(), which covers its own pluginchapter 5
not foundapp.setNotFoundHandler()chapter 5
testsawait app.inject({ method, url })chapter 5
startawait app.listen({ port, host })chapter 3

Nunjucks#

You wantWrite
print a value, escaped{{ value }}
choose{% if %}...{% elif %}...{% else %}...{% endif %}
loop{% for x in list %}...{% else %}...{% endfor %}, with loop.index, loop.first, loop.last
name a value{% set x = 1 %}, or capture a block with {% set x %}...{% endset %}
a component{% macro name(a, b=1) %}...{% endmacro %}, with caller() for content
call it with content{% call name() %}...{% endcall %}
another template, with the page's variables{% include "x.njk" %}
macros from another file{% import "x.njk" as x %}, {% from "x.njk" import a %}, and with context to share the page's variables
inheritance{% extends "layout.njk" %}, {% block name %}...{% endblock %}, {{ super() }}
a filter over a block{% filter upper %}...{% endfilter %}
Nunjucks syntax as text{% raw %}...{% endraw %}
a comment{# ... #}
trim whitespace{%- -%}, {{- -}}

Every filter, test and global, each with a rendered example, is in the tables in chapter 8.

CASA 10#

You wantWriteTaught in
to set upconfigure({ views, session: { secret, secure }, pages, plan }), then mount(app)chapter 16
a page{ waypoint, view, fields, hooks }chapter 15
a fieldfield(name, { optional }), .validators([...]), .processors([...]), .if(condition)chapter 16
a validatorvalidators.required.make({ errorMsg }), and nine morechapter 16
a validator of your owna class that extends ValidatorFactory, with name and validate()chapter 16
the plannew Plan(), addSequence(), setRoute(from, to, condition), addSkippables()chapter 16
hooks, in orderGET: presteer, poststeer, prerender; POST: presteer to postvalidate, then preredirect or prerenderchapter 16
answers in codereq.casa.journeyContext.datachapter 16
answers in a templateformData, formErrorschapter 16
a change linkwaypointUrl({ waypoint, edit: true, editOrigin })chapter 16
a page outside the planancillaryRouter.get(path, handler)chapter 16
translatet('namespace:key'), and ?lang=cy to switchchapter 16
end the sessionendSession(req, callback), then redirectchapter 16

Where the answer lives#

QuestionLook here
every option of a component's macrothe component's page on the Design System site, Nunjucks tab
how a pattern should behavethe Design System's patterns
what a Service Standard point asksthe Service Manual
installing and serving GOV.UK FrontendGOV.UK Frontend's documentation
CASA's APICASA's repository and examples, and the type definitions in its npm package
an Express methodthe Express 5.x API reference
a Fastify method or hookthe Reference section of the Fastify documentation

Libraries used in this book#

LibraryWhat it does here
Expressthe web server: an ordered chain of middleware, then routes
Fastifythe other web server: plugins, hooks and routes
Nunjucksthe template language GOV.UK Frontend's macros are written in
GOV.UK Frontendthe page template, the component macros, and their CSS and JavaScript
CASADWP's form-journey framework, on Express; the npm package is @dwp/govuk-casa
helmetsecurity headers, including the CSP nonce, for Express
express-sessionserver-side sessions for Express
csrf-syncCSRF tokens for Express forms
@fastify/viewrenders Nunjucks templates in Fastify
@fastify/staticserves GOV.UK Frontend's files in Fastify
@fastify/formbodyparses form posts in Fastify
@fastify/cookiecookies for Fastify; @fastify/session needs it
@fastify/sessionserver-side sessions for Fastify
@fastify/csrf-protectionCSRF tokens for Fastify forms
@fastify/helmetsecurity headers and CSP nonces for Fastify
fastify-pluginshares a Fastify plugin with the whole app
cookie-parserreads cookies into req.cookies for Express
Ajvthe JSON Schema validator behind Fastify's route schemas
pinothe JSON logger inside Fastify, and behind pino-http
pino-httprequest logging with pino for Express
supertestsends requests to an Express app in tests
TypeScriptthe example app's language, with erasableSyntaxOnly
GOV.UK Prototype Kitfor prototypes, not production services

CASA OptionalExpress only#

Part VI · CASA · Chapter 15·4 min read

CASA is DWP's framework for form journeys. You describe pages, fields and a plan, and CASA runs the request loop, sessions, CSRF protection and validation for you. It runs on Express only.

CASA from zero#

CASA is an npm package, @dwp/govuk-casa, and needs Node 22 or later. With CASA you do not write routes for question pages. You give it three things, and it builds the routes, the session, the CSRF protection and the validation from them:

  • Pages. Each page names a waypoint, which is also its address, a template, and the fields it collects, each field with its validators.
  • A plan. Which waypoint follows which, with a condition on any route that branches.
  • Templates. Nunjucks pages that extend CASA's journey layout and use its casaGovuk* macros, which fill in each field's id and error message.
CASA's parts: your pages, plan and templates go into configure(), which builds the routers and middleware that mount() adds to your Express app. CASA's parts. You write three things: pages, each with a waypoint, a view and fields with validators; a plan of routes between waypoints; and templates that extend CASA's journey layout and use its casaGovuk macros. All of them go into configure(). From them CASA builds a journey router with a GET and a POST for every waypoint; middleware for the session, CSRF protection, translations and security headers; and routers for static files and ancillary routes such as a confirmation page. mount(app) then adds all of it to your own Express app. YOU WRITE CASA BUILDS pages waypoint, view, fields a plan routes between waypoints templates journey layout, casaGovuk macros configure() journey router a GET and a POST for every waypoint middleware session, CSRF, translations, headers other routers static files, ancillary routes your Express app mount(app)
Figure CASA's parts: your pages, plan and templates go into configure(), which builds the routers and middleware that mount() adds to your Express app.#
As text

CASA's parts. You write three things: pages, each with a waypoint, a view and fields with validators; a plan of routes between waypoints; and templates that extend CASA's journey layout and use its casaGovuk macros. All of them go into configure(). From them CASA builds a journey router with a GET and a POST for every waypoint; middleware for the session, CSRF protection, translations and security headers; and routers for static files and ancillary routes such as a confirmation page. mount(app) then adds all of it to your own Express app.

CASA keeps every answer in a journey context in the session, keyed by waypoint and then by field, so the full name lives at data.name.fullName. A route condition gets it as its second argument, and a hook reads it from req.casa.journeyContext. On a GET, CASA renders the waypoint's template with that page's saved answers as formData and any errors as formErrors.

Note
CASA's documentation and examples live in its repository, linked above. The npm package has the current version's code and type definitions. When this book was checked, the repository's main branch still showed version 9, while npm had 10.4.0, so read the documentation against the version you install.

The plan and the pages#

The CASA version's plan: waypoints and routes; the confirmation page sits outside the planyesnonameemailfirefire-certificatecheck-answers
Figure The CASA version's plan: waypoints and routes; the confirmation page sits outside the plan#
As text

The journey starts at name.

  1. name, then email.
  2. email, then fire.
  3. fire asks a question. Yes: fire-certificate. No: check-answers.
  4. fire-certificate, then check-answers.
  5. check-answers is the final page.

A route that branches carries a condition that reads the answers so far:

casa/app.tscasa/app.jsplan
export function buildPlan(
  questions = ['name', 'email', 'fire'],
) {
  const plan = new Plan();
  plan.addSequence(...questions);
  plan.setRoute(
    'fire',
    'fire-certificate',
    (route: PlanRoute, context: JourneyContext) =>
      context.data.fire?.fire === 'yes',
  );
  plan.setRoute(
    'fire',
    'check-answers',
    (route: PlanRoute, context: JourneyContext) =>
      context.data.fire?.fire === 'no',
  );
  plan.setRoute('fire-certificate', 'check-answers');
  return plan;
}
export function buildPlan(
  questions = ['name', 'email', 'fire'],
) {
  const plan = new Plan();
  plan.addSequence(...questions);
  plan.setRoute(
    'fire',
    'fire-certificate',
    (route, context) => context.data.fire?.fire === 'yes',
  );
  plan.setRoute(
    'fire',
    'check-answers',
    (route, context) => context.data.fire?.fire === 'no',
  );
  plan.setRoute('fire-certificate', 'check-answers');
  return plan;
}

Each page names its template and its fields, and the validators hang off the fields. CASA then validates, shows the page again with errors, saves and redirects, with no route code at all:

casa/app.tscasa/app.jspage-name
{
  waypoint: 'name',
  view: 'pages/name.njk',
  fields: [
    field('fullName').validators([
      validators.required.make({
        errorMsg: 'Enter your full name',
      }),
    ]),
  ],
},
{
  waypoint: 'name',
  view: 'pages/name.njk',
  fields: [
    field('fullName').validators([
      validators.required.make({
        errorMsg: 'Enter your full name',
      }),
    ]),
  ],
},
casa/views/pages/name.njkfield
{{ casaGovukInput({
  name: "fullName",
  value: formData.fullName,
  casaErrors: formErrors,
  label: {
    text: "What is your full name?",
    classes: "govuk-label--l",
    isPageHeading: true
  },
  autocomplete: "name",
  spellcheck: false
}) }}
What CASA does with a POST to a waypoint, and the hooks you can add at each stepyesnosteer: may the user be here? (presteer,poststeer)sanitise the fields (presanitise,postsanitise)gather them into the journey context(pregather, postgather)validate (prevalidate, postvalidate):any errors?show the page with errors(prerender)redirect to the next waypoint in theplan (preredirect)
Figure What CASA does with a POST to a waypoint, and the hooks you can add at each step#
As text
  1. steer: may the user be here? (presteer, poststeer)
  2. sanitise the fields (presanitise, postsanitise)
  3. gather them into the journey context (pregather, postgather)
  4. validate (prevalidate, postvalidate): any errors? Yes: show the page with errors (prerender). No: the next step.
  5. redirect to the next waypoint in the plan (preredirect)

Check answers and submitting#

The check answers page is a waypoint with no fields. A prerender hook builds the rows. A preredirect hook sends the application, ends the session with CASA's endSession(), and redirects to a confirmation route outside the plan.

casa/app.tscasa/app.jscheck-answers
const showAnswers: RequestHandler = (req, res, next) => {
  const context = req.casa.journeyContext;
  res.locals.rows = summaryRows(
    context.data,
    req.casa.plan.traverse(context),
    (page) => `/${page}?edit=true&editorigin=/check-answers`,
  );
  next();
};

const submit: RequestHandler = (req, res, next) => {
  // a real service sends req.casa.journeyContext.data to its API
  const reference = newReference();
  endSession(req, (err?: Error) => {
    if (err) return next(err);
    req.session.reference = reference;
    req.session.save(() => res.redirect('/confirmation'));
  });
};
const showAnswers = (req, res, next) => {
  const context = req.casa.journeyContext;
  res.locals.rows = summaryRows(
    context.data,
    req.casa.plan.traverse(context),
    (page) => `/${page}?edit=true&editorigin=/check-answers`,
  );
  next();
};

const submit = (req, res, next) => {
  // a real service sends req.casa.journeyContext.data to its API
  const reference = newReference();
  endSession(req, (err) => {
    if (err) return next(err);
    req.session.reference = reference;
    req.session.save(() => res.redirect('/confirmation'));
  });
};
casa/app.tscasa/app.jsconfigure
const { mount, ancillaryRouter } = configure({
  views: [join(import.meta.dirname, 'views')],
  i18n: {
    dirs: [join(import.meta.dirname, 'locales')],
    locales: ['en'],
  },
  session: {
    secret: sessionSecret,
    secure: process.env.NODE_ENV === 'production',
  },
  pages: allPages,
  plan,
});
const { mount, ancillaryRouter } = configure({
  views: [join(import.meta.dirname, 'views')],
  i18n: {
    dirs: [join(import.meta.dirname, 'locales')],
    locales: ['en'],
  },
  session: {
    secret: sessionSecret,
    secure: process.env.NODE_ENV === 'production',
  },
  pages: allPages,
  plan,
});
casa/app.tscasa/app.jsmount
const app = express();
mount(app, { serveFirstWaypoint: true });
const app = express();
mount(app, { serveFirstWaypoint: true });

CASA and the hand-rolled version#

JobHand-rolled, in Express or FastifyCASA
the journeyquestions and next() in journey.tsa Plan, with setRoute conditions
a pagea GET route and a POST routea page object: waypoint, view and fields
validationvalidators.ts, called in the POSTfield(...).validators([...])
no skipping aheadroute(), checked in the GETbuilt in
sessions, CSRF, security headersthe plumbing in chapter 6built in, set up by configure()
errors in templateserrorList and fieldErrorsformErrors, and the casaGovuk* macros
Change links?change=true?edit=true&editorigin=...
the "Error: " titlethe layout adds itcasaPageTitle adds it
field idsthe field's name, such as fullNamef- and the name, such as f-fullName
Gotcha
CASA brings its own GOV.UK Frontend. CASA 10.4.0 pins govuk-frontend 6.4.0 and installs its own copy, whatever version your app asks for. Check which copy your pages render with before you upgrade either.
Gotcha
CASA will not start without its session settings. configure() throws unless you pass session.secret, and set session.secure to true or false. Pass the secret from your secrets store. Without a shared session.store, CASA keeps sessions in memory, and logs a warning that this "is not suitable for production".
Note
CASA's templates pass every string through t(), for Welsh and other languages. The service name in the header comes from common:serviceName, so the example adds a locales folder that sets it.

Choose CASA for a long, form-filling journey on Express, where its plan, validation and edit mode save you code. Write the journey yourself when you are on Fastify, or when your pages do not fit the waypoint model.

On the job#
Find it
In a CASA app, open the file that calls configure(). The pages hold the fields and validators, the Plan holds the routes, and the views folder holds the templates.
Add it
Start from configure() with a plan and pages, mount it on an Express app, and add your confirmation page as an ancillary route.

CASA in depth OptionalExpress only#

Part VI · CASA · Chapter 16·11 min read

Every part of CASA a service uses, from configure() to the journey context, hooks, macros and translations, and what CASA 9 code looks like.

configure(), in full#

This chapter's examples come from a second CASA app beside the service, "Renew a juggling licence". Its configure() call uses most of the options:

depth/casa/renewal.tsdepth/casa/renewal.jsconfigure
const { mount, ancillaryRouter } = configure({
  views: [join(import.meta.dirname, 'views')],
  i18n: {
    dirs: [join(import.meta.dirname, 'locales')],
    locales: ['en', 'cy'],
    fallbackLng: 'en',
  },
  session: {
    secret: sessionSecret,
    secure: process.env.NODE_ENV === 'production',
    ttl: 60 * 60,
    // in production: a store every copy of the service shares
    store: new MemoryStore(),
  },
  pages,
  plan,
  hooks: [
    {
      hook: 'journey.prerender',
      path: '/check-answers',
      middleware: answers,
    },
  ],
});
const { mount, ancillaryRouter } = configure({
  views: [join(import.meta.dirname, 'views')],
  i18n: {
    dirs: [join(import.meta.dirname, 'locales')],
    locales: ['en', 'cy'],
    fallbackLng: 'en',
  },
  session: {
    secret: sessionSecret,
    secure: process.env.NODE_ENV === 'production',
    ttl: 60 * 60,
    // in production: a store every copy of the service shares
    store: new MemoryStore(),
  },
  pages,
  plan,
  hooks: [
    {
      hook: 'journey.prerender',
      path: '/check-answers',
      middleware: answers,
    },
  ],
});
OptionDefaultWhat it does
viewsnoneyour template folders, searched before CASA's own and GOV.UK Frontend's
session.secretnone: requiredsigns the session cookie; configure() throws without it
session.securenone: requiredtrue sends the cookie over HTTPS only; configure() throws unless you set it
session.namecasa-sessionthe cookie's name
session.ttl3600seconds of inactivity before the session expires
session.storememory, with a warningwhere sessions live; use a store every copy of the service shares, such as Redis
session.cookieSameSite, session.cookiePathStrict, /the cookie's SameSite and Path attributes
pages, plannonethe waypoints' templates and fields, and the routes between them
hooksnonemiddleware at named points, for every waypoint or for one path
i18nlocales en and cytranslation folders, the languages, and a fallbackLng for missing text
eventsnonefunctions that run when the journey context changes
pluginsnonepackages that change the configuration, or the routers
mountUrlthe path you mount onthe URL prefix when a proxy rewrites paths; it needs a trailing slash
errorVisibilityon submitwhether a page's errors show again on a plain GET
helmetConfiguratornonea function that changes CASA's security headers
formMaxParams, formMaxBytes25 fields, 50 KBlimits on a form body
contextIdGeneratorUUIDshow ephemeral contexts get their ids

configure() returns everything it built. Each part can still change until you call mount(), which adds them to your Express app in a fixed order and seals them:

It returnsWhat it is
staticRouter, ancillaryRouter, journeyRouterrouters for CASA's assets, for pages outside the plan, and for the waypoints
preMiddleware, postMiddlewarewhat runs first, such as the security headers, and last: the 404 and error pages
sessionMiddleware, cookieParserMiddlewarethe session and its signed cookie
i18nMiddleware, bodyParserMiddleware, dataMiddlewaretranslations, form bodies, and req.casa
csrfMiddlewareCSRF protection, for forms you add yourself
nunjucksEnvthe template environment, to add filters or share with another app
mount(app, { route, serveFirstWaypoint })adds all of it to an Express app
Gotcha
Set secure from the environment. Tests and local runs use HTTP, where a secure cookie is never sent back, so the session is lost. The example sets it from NODE_ENV, as the service does. Behind a proxy that ends HTTPS, Express must also trust the proxy, as in chapter 4, or the secure cookie is not set.

Pages and fields#

A page names its waypoint, its template and its fields. A field has a name, and can have processors, validators and conditions:

depth/casa/renewal.tsdepth/casa/renewal.jsfields
const tidy = (value: unknown) =>
  String(value ?? '')
    .trim()
    .toUpperCase();

const pages = [
  {
    waypoint: 'licence',
    view: 'pages/licence.njk',
    fields: [
      // processors run before the validators, and change what is saved
      field('licenceNumber')
        .processors([tidy])
        .validators([
          validators.required.make({
            errorMsg: 'renewal:licence.errors.required',
          }),
          validators.regex.make({
            pattern: /^JL-\d{4}$/,
            errorMsg: 'renewal:licence.errors.format',
          }),
        ]),
    ],
  },
  {
    waypoint: 'props',
    view: 'pages/props.njk',
    fields: [
      // CASA's checkboxes macro names the field props[], so the
      // answer is always a list, even with one box ticked
      field('props').validators([
        validators.required.make({
          errorMsg: 'renewal:props.errors.required',
        }),
        validators.inArray.make({
          source: ['clubs', 'rings', 'knives'],
          errorMsg: 'renewal:props.errors.required',
        }),
      ]),
    ],
  },
  {
    waypoint: 'fire',
    view: 'pages/fire.njk',
    fields: [
      field('fire').validators([
        validators.inArray.make({
          source: ['yes', 'no'],
          errorMsg: 'renewal:fire.errors.required',
        }),
      ]),
      // checked only when the answer on this page is yes
      field('certificate')
        .processors([tidy])
        .validators([
          validators.regex.make({
            pattern: /^FS-\d{6}$/,
            errorMsg: 'renewal:fire.errors.certificate',
          }),
        ])
        .if(
          ({
            journeyContext,
            waypoint,
          }: {
            journeyContext: JourneyContext;
            waypoint: string;
          }) => journeyContext.data[waypoint]?.fire === 'yes',
        ),
    ],
  },
  {
    waypoint: 'check-answers',
    view: 'pages/check-answers.njk',
    hooks: [{ hook: 'preredirect', middleware: submit }],
  },
];
const tidy = (value) =>
  String(value ?? '')
    .trim()
    .toUpperCase();

const pages = [
  {
    waypoint: 'licence',
    view: 'pages/licence.njk',
    fields: [
      // processors run before the validators, and change what is saved
      field('licenceNumber')
        .processors([tidy])
        .validators([
          validators.required.make({
            errorMsg: 'renewal:licence.errors.required',
          }),
          validators.regex.make({
            pattern: /^JL-\d{4}$/,
            errorMsg: 'renewal:licence.errors.format',
          }),
        ]),
    ],
  },
  {
    waypoint: 'props',
    view: 'pages/props.njk',
    fields: [
      // CASA's checkboxes macro names the field props[], so the
      // answer is always a list, even with one box ticked
      field('props').validators([
        validators.required.make({
          errorMsg: 'renewal:props.errors.required',
        }),
        validators.inArray.make({
          source: ['clubs', 'rings', 'knives'],
          errorMsg: 'renewal:props.errors.required',
        }),
      ]),
    ],
  },
  {
    waypoint: 'fire',
    view: 'pages/fire.njk',
    fields: [
      field('fire').validators([
        validators.inArray.make({
          source: ['yes', 'no'],
          errorMsg: 'renewal:fire.errors.required',
        }),
      ]),
      // checked only when the answer on this page is yes
      field('certificate')
        .processors([tidy])
        .validators([
          validators.regex.make({
            pattern: /^FS-\d{6}$/,
            errorMsg: 'renewal:fire.errors.certificate',
          }),
        ])
        .if(
          ({ journeyContext, waypoint }) =>
            journeyContext.data[waypoint]?.fire === 'yes',
        ),
    ],
  },
  {
    waypoint: 'check-answers',
    view: 'pages/check-answers.njk',
    hooks: [{ hook: 'preredirect', middleware: submit }],
  },
];
  • Processors change the value before it is checked and saved. tidy turns " jl-0042 " into JL-0042.
  • Conditions, added with .if(), decide whether the field's validators run. The certificate is checked only when the answer on the same page is "yes".
  • Optional fields: field(name, { optional: true }) skips the validators when the field is empty.
  • Nested names: field('address[postcode]') saves an object, one level deep only.

CASA saves only the fields a page lists: anything else in the form body is dropped.

Gotcha
A condition stops the validators, not the saving. A conditionally revealed input is still in the form when it is hidden, so it is still sent. If the user typed a certificate, then changed "yes" to "no", the certificate is saved. Clear it in a processor or a hook if it must not reach your API.

Validators#

Each built-in validator is made with make(), which takes its options and an errorMsg. The message can be a string, a translation key, an object with summary and inline text, or a function that returns one of those.

ValidatorIt checksOptions, besides errorMsg
requiredthe value is not emptynone
emailan email addressnone
inArraythe value, or every item of a list, is in sourcesource
regexthe value matches pattern, or with invert, does notpattern, invert
strlenthe length of the textmin, max, errorMsgMin, errorMsgMax
wordCountthe number of wordsmin, max, errorMsgMin, errorMsgMax
rangea number between limitsmin, max, errorMsgMin, errorMsgMax, errorMsgInvalid
dateObjecta real date, from day, month and year fieldsallowSingleDigitDay, allowSingleDigitMonth, allowMonthNames, afterOffsetFromNow, beforeOffsetFromNow, and their messages
ninoa National Insurance numberallowWhitespace
postalAddressObjectan address, from separate fieldsrequiredFields, strlenmax, and a message for each part
Gotcha
The default messages are not GOV.UK messages. Without an errorMsg, required says "Information is required". Always pass your own, in the Design System's wording.
Gotcha
dateObject needs message objects. Its messages must be objects with summary and inline: a plain string throws "Cannot create property 'focusSuffix'". It also refuses 1 for a day or month unless you allow single digits.

The plan in depth#

addSequence() joins waypoints with routes that go both ways, so the Back link works. setRoute(from, to, condition) adds one pair of routes, and setNextRoute() and setPrevRoute() add one direction only:

depth/casa/renewal.tsdepth/casa/renewal.jsplan
const plan = new Plan({ arbiter: 'auto' });
plan.addSequence('licence', 'props', 'fire', 'check-answers');
// a waypoint the user may skip, with ?skipto=
plan.addSkippables('props');
const plan = new Plan({ arbiter: 'auto' });
plan.addSequence('licence', 'props', 'fire', 'check-answers');
// a waypoint the user may skip, with ?skipto=
plan.addSkippables('props');
  • Conditions run only after the page they leave has passed validation, unless the plan is made with validateBeforeRouteCondition: false.
  • Skippable waypoints can be passed with ?skipto=, as the props page's "I will not use props" link does. Skipping a page replaces its answers.
  • Exit nodes, written url:///path/, send the user to another CASA app mounted in the same service.
  • plan.traverse(context) lists the waypoints the user's answers lead through. The service's check answers page uses it to list the rows.
  • arbiter: 'auto' lets CASA choose a Back link when old answers make two routes look valid.

The journey context#

The journey context is the user's answers and their validation state, kept in the session. Everything in CASA reads from it:

CASA's journey context: its four parts, where it lives in the session, and who reads it. CASA's journey context. The session holds a list of journey contexts: the default one, and any ephemeral ones, chosen with a contextid parameter. Each context has four parts. data holds the answers, keyed by waypoint and then by field, for example data.licence.licenceNumber. validation holds each waypoint's errors, or none once the page passes. nav holds the language. identity holds the context's id, name and tags. Hooks read it as req.casa.journeyContext. A route condition gets it as its second argument. A template gets the current page's part of it: formData for the answers and formErrors for the errors. When an earlier answer changes, the plan decides the route again, and answers for pages no longer on the route stay in data unless you purge them. req.session the default context ephemeral contexts, chosen with ?contextid= a JourneyContext data answers by waypoint, then field: data.licence.licenceNumber validation each waypoint's errors, or none nav the language identity id, name and tags hooks req.casa.journeyContext route conditions as their second argument templates this page's part only: formData, formErrors When an earlier answer changes, the plan decides the route again. Answers for pages no longer on the route stay in data, unless you purge them.
Figure CASA's journey context: its four parts, where it lives in the session, and who reads it.#
As text

CASA's journey context. The session holds a list of journey contexts: the default one, and any ephemeral ones, chosen with a contextid parameter. Each context has four parts. data holds the answers, keyed by waypoint and then by field, for example data.licence.licenceNumber. validation holds each waypoint's errors, or none once the page passes. nav holds the language. identity holds the context's id, name and tags. Hooks read it as req.casa.journeyContext. A route condition gets it as its second argument. A template gets the current page's part of it: formData for the answers and formErrors for the errors. When an earlier answer changes, the plan decides the route again, and answers for pages no longer on the route stay in data unless you purge them.

You want toWrite
read one page's answerscontext.getDataForPage('fire'), or context.data.fire
change themcontext.setDataForPage('fire', { fire: 'no' }), then JourneyContext.putContext(req.session, context)
read a page's errorscontext.getValidationErrorsForPage('fire')
know whether a page passedcontext.isPageValid('fire')
remove old answerscontext.purge(['fire-certificate'])
keep a second set of answersJourneyContext.createEphemeralContext(req), chosen later with ?contextid=

Hooks, in order#

A hook is Express middleware that CASA runs at a named point. A global hook is named with the router's scope, as journey.prerender, and can be limited to one path. A page hook leaves out the scope, and runs after the global hooks. On a GET:

What CASA does with a GET for a waypoint, and the hooks around each stepnoyespresteer hooksmay the user be on this page yet?redirect them to the page theyshould be onpoststeer hooksprerender hooks: add data for thetemplaterender the page, with this page's savedanswers as formData
Figure What CASA does with a GET for a waypoint, and the hooks around each step#
As text
  1. presteer hooks
  2. may the user be on this page yet? No: redirect them to the page they should be on. Yes: the next step.
  3. poststeer hooks
  4. prerender hooks: add data for the template
  5. render the page, with this page's saved answers as formData

On a POST, CASA runs the steps in chapter 15's figure. The example app's test records every hook and proves this order:

HookRuns onWhen
presteer, poststeerGET and POSTaround the check that stops the user skipping ahead
presanitise, postsanitisePOSTaround tidying the body: unlisted fields dropped, processors run
pregather, postgatherPOSTaround saving the answers in the journey context
prevalidate, postvalidatePOSTaround validation
preredirectPOST, when the page is validbefore the redirect to the next waypoint
prerenderGET, and a POST with errorsbefore the page is rendered
Gotcha
Errors show once. A POST with errors renders the page with them. A plain GET of the same page afterwards shows no errors, unless errorVisibility is set to always.

Templates and macros#

A waypoint's template extends casa/layouts/journey.njk, and fills two blocks. casaPageTitle gets "Error: " added when there are errors. journey_form sits inside a form with the CSRF token and the continue button. Other pages extend casa/layouts/main.njk. CASA gives every template these values:

ValueWhat it holds
formDatathis page's saved answers
formErrorsthis page's errors, by field, after a POST that failed
casa.csrfTokenthe CSRF token, for a form you write yourself
casa.waypoint, casa.mountUrlthe current waypoint, and where the app is mounted
casa.editMode, casa.editOriginwhether the page is being edited, and where to go back to
t()translates a key
waypointUrl()builds a waypoint's URL, already set up with the mount URL and the journey context
htmlLang, cspNonce, assetPathwhat GOV.UK Frontend's page template needs

CASA's macros wrap GOV.UK Frontend's, and take the same options, plus casaErrors and, for some, casaValue. They fill in the id, the name and the error message the way CASA expects:

MacroCASA adds
casaGovukInput, casaGovukTextarea, casaGovukCharacterCount, casaGovukSelectan id of f- and the name, and the field's error from casaErrors
casaGovukRadios, casaGovukCheckboxesthe same; checkboxes are named with [], so the answer is always a list, and are ticked from casaValue
casaGovukDateInputfields named name[dd], name[mm] and name[yyyy], filled from casaValue
casaPostalAddressObjectthe separate address fields that postalAddressObject checks
casaJourneyForma form with the CSRF token and a button, for pages such as check answers

A conditionally revealed field is a second macro, captured in a {% set %} block and passed as the radio item's html:

depth/casa/views/pages/fire.njkconditional
{% set certificateHtml %}
  {{ casaGovukInput({
    name: "certificate",
    value: formData.certificate,
    casaErrors: formErrors,
    label: { text: t("renewal:fire.certificate") },
    classes: "govuk-input--width-10"
  }) }}
{% endset %}
{{ casaGovukRadios({
  name: "fire",
  value: formData.fire,
  casaErrors: formErrors,
  fieldset: {
    legend: {
      text: t("renewal:fire.title"),
      classes: "govuk-fieldset__legend--l",
      isPageHeading: true
    }
  },
  items: [
    { value: "yes", text: "Yes", conditional: { html: certificateHtml } },
    { value: "no", text: "No" }
  ]
}) }}

Translations#

Each translation folder has a folder for each language, such as en and cy. Each file in it is a namespace, so renewal.json holds the keys that t("renewal:licence.title") finds. req.t() does the same in code. Adding ?lang=cy to any address switches the language, and CASA keeps the choice in the session. With fallbackLng: 'en', a key with no Welsh text shows the English.

depth/casa/views/pages/licence.njkfield
{{ casaGovukInput({
  name: "licenceNumber",
  value: formData.licenceNumber,
  casaErrors: formErrors,
  label: {
    text: t("renewal:licence.title"),
    classes: "govuk-label--l",
    isPageHeading: true
  },
  hint: { text: t("renewal:licence.hint") },
  classes: "govuk-input--width-10",
  spellcheck: false
}) }}
Note
The example's Welsh is a placeholder, to show the mechanism. A real service gets its Welsh from a translator.

Edit mode and check answers#

A Change link opens a page in edit mode: edit=true, and an editorigin to return to. The template builds the links with waypointUrl():

depth/casa/views/pages/check-answers.njkrows
{% macro change(waypoint, label) %}
  {{- waypointUrl({ waypoint: waypoint, edit: true, editOrigin: "/check-answers" }) -}}
{% endmacro %}
{{ govukSummaryList({ rows: [
  {
    key: { text: t("renewal:licence.title") },
    value: { text: answers.licence.licenceNumber },
    actions: { items: [{ href: change("licence"), text: t("renewal:check.change"), visuallyHiddenText: t("renewal:licence.title") }] }
  },
  {
    key: { text: t("renewal:props.title") },
    value: { text: answers.props.props | join(", ") if answers.props.props else t("renewal:props.skip") },
    actions: { items: [{ href: change("props"), text: t("renewal:check.change"), visuallyHiddenText: t("renewal:props.title") }] }
  },
  {
    key: { text: t("renewal:fire.title") },
    value: { text: answers.fire.fire },
    actions: { items: [{ href: change("fire"), text: t("renewal:check.change"), visuallyHiddenText: t("renewal:fire.title") }] }
  }
] }) }}
The change-link round trip: in edit mode, CASA sends the user back to the page they came fromBrowserCASAGET /check-answers1Change links to /licence?edit=true&editorigin=/check-answers2GET the Change link3the page, with a Cancel link back to theorigin4POST the new answer, still in edit mode5validate and save6302 to /check-answers, keeping the edit flags7GET /check-answers8
Figure The change-link round trip: in edit mode, CASA sends the user back to the page they came from#
As text
  1. Browser to CASA: GET /check-answers
  2. CASA to Browser: Change links to /licence?edit=true&editorigin=/check-answers
  3. Browser to CASA: GET the Change link
  4. CASA: the page, with a Cancel link back to the origin
  5. Browser to CASA: POST the new answer, still in edit mode
  6. CASA: validate and save
  7. CASA to Browser: 302 to /check-answers, keeping the edit flags
  8. Browser to CASA: GET /check-answers

CASA keeps edit=true and the editorigin on the way back, on purpose: the test sees the redirect go to /check-answers?edit=true&editorigin=%2Fcheck-answers.

Outside the journey#

Pages that are not waypoints, such as a start page or a cookies page, go on the ancillary router, which already has the session, translations and templates:

depth/casa/renewal.tsdepth/casa/renewal.jsancillary
// pages outside the plan
ancillaryRouter.get('/', (req: Request, res: Response) =>
  res.render('start.njk'),
);
ancillaryRouter.get(
  '/done',
  (req: Request, res: Response) => res.render('done.njk'),
);
// pages outside the plan
ancillaryRouter.get('/', (req, res) =>
  res.render('start.njk'),
);
ancillaryRouter.get('/done', (req, res) =>
  res.render('done.njk'),
);

A page hook on check answers sends the application, then ends the session with endSession(), which clears the answers and keeps only the language:

depth/casa/renewal.tsdepth/casa/renewal.jshooks
// every answer, for the check answers page
const answers: RequestHandler = (req, res, next) => {
  res.locals.answers = req.casa.journeyContext.data;
  next();
};

// send the application, then end the session
function submit(
  ...[req, res, next]: Parameters<RequestHandler>
) {
  // a real service sends req.casa.journeyContext.data to its API here
  endSession(req, (err?: Error) =>
    err ? next(err) : res.redirect('/done'),
  );
}
// every answer, for the check answers page
const answers = (req, res, next) => {
  res.locals.answers = req.casa.journeyContext.data;
  next();
};

// send the application, then end the session
function submit(...[req, res, next]) {
  // a real service sends req.casa.journeyContext.data to its API here
  endSession(req, (err) =>
    err ? next(err) : res.redirect('/done'),
  );
}
  • Sessions in production need a session.store that every copy of the service shares, such as Redis. The memory store loses every session when the service restarts.
  • Events in configure() run a function when a waypoint's answers change, or on every change to the journey context. They must not be asynchronous.
  • Plugins can change the configuration before CASA reads it, and the routers after it has built them.

CASA apps have no inject(). Test them as the Express service is tested: start the app on a free port, keep its cookies, and send back the CSRF token from each page's form.

Reading CASA 9 code#

CASA 10's public functions are the same as CASA 9's. This app is written the way a CASA 9 service would write it, and the example app's tests run it unchanged on CASA 9 with Express 4, and on CASA 10 with Express 5:

legacy/casa9/app.cjsapp
const { configure, field, Plan, validators } = casa;

const plan = new Plan();
plan.addSequence('name', 'email');

const { mount } = configure({
  views: [views],
  session: {
    secret: 'development-only-secret-of-at-least-32-characters',
    secure: false,
  },
  pages: [
    {
      waypoint: 'name',
      view: 'pages/name.njk',
      fields: [
        field('fullName').validators([
          validators.required.make({ errorMsg: 'Enter your full name' }),
        ]),
      ],
    },
    { waypoint: 'email', view: 'pages/email.njk', fields: [field('email')] },
  ],
  plan,
});

const app = express();
mount(app, { serveFirstWaypoint: true });
CASA 9CASA 10
Node 18 to 24Node 22 or later
Express 4Express 5, so your own routes change: see chapter 5
govuk-frontend 5, and a govukRebrand optiongovuk-frontend 6; the option is gone, and the new header is always used
layout blocks beforeContent, skipLink and headercontainerStart, govukSkipLink and govukHeader
translation files ending .json or .yaml.yml as well
Gotcha
A renamed block fails silently. A CASA 9 template that overrides beforeContent in the journey layout still renders on CASA 10, but its content never appears. Search your templates for the three old block names when you upgrade.

Read it: the service's CASA pages#

These are the pages of the service's CASA version, which you met in chapter 15. Read the code, then the notes.

casa/app.tscasa/app.jspages
export const pages = [
  {
    waypoint: 'name',
    view: 'pages/name.njk',
    fields: [
      field('fullName').validators([
        validators.required.make({
          errorMsg: 'Enter your full name',
        }),
      ]),
    ],
  },
  {
    waypoint: 'email',
    view: 'pages/email.njk',
    fields: [
      field('email').validators([
        validators.required.make({
          errorMsg: 'Enter your email address',
        }),
        validators.email.make({
          errorMsg:
            'Enter an email address in the correct format, like name@example.com',
        }),
      ]),
    ],
  },
  {
    waypoint: 'fire',
    view: 'pages/fire.njk',
    fields: [
      field('fire').validators([
        validators.inArray.make({
          source: ['yes', 'no'],
          errorMsg: 'Select yes if you will juggle fire',
        }),
      ]),
    ],
  },
  {
    waypoint: 'fire-certificate',
    view: 'pages/fire-certificate.njk',
    fields: [
      field('certificate').validators([
        validators.required.make({
          errorMsg:
            'Enter your fire safety certificate number',
        }),
        validators.regex.make({
          pattern: /^FS-\d{6}$/i,
          errorMsg:
            'Enter a certificate number in the correct format, like FS-123456',
        }),
      ]),
    ],
  },
  {
    waypoint: 'check-answers',
    view: 'pages/check-answers.njk',
    hooks: [
      { hook: 'prerender', middleware: showAnswers },
      { hook: 'preredirect', middleware: submit },
    ],
  },
];
export const pages = [
  {
    waypoint: 'name',
    view: 'pages/name.njk',
    fields: [
      field('fullName').validators([
        validators.required.make({
          errorMsg: 'Enter your full name',
        }),
      ]),
    ],
  },
  {
    waypoint: 'email',
    view: 'pages/email.njk',
    fields: [
      field('email').validators([
        validators.required.make({
          errorMsg: 'Enter your email address',
        }),
        validators.email.make({
          errorMsg:
            'Enter an email address in the correct format, like name@example.com',
        }),
      ]),
    ],
  },
  {
    waypoint: 'fire',
    view: 'pages/fire.njk',
    fields: [
      field('fire').validators([
        validators.inArray.make({
          source: ['yes', 'no'],
          errorMsg: 'Select yes if you will juggle fire',
        }),
      ]),
    ],
  },
  {
    waypoint: 'fire-certificate',
    view: 'pages/fire-certificate.njk',
    fields: [
      field('certificate').validators([
        validators.required.make({
          errorMsg:
            'Enter your fire safety certificate number',
        }),
        validators.regex.make({
          pattern: /^FS-\d{6}$/i,
          errorMsg:
            'Enter a certificate number in the correct format, like FS-123456',
        }),
      ]),
    ],
  },
  {
    waypoint: 'check-answers',
    view: 'pages/check-answers.njk',
    hooks: [
      { hook: 'prerender', middleware: showAnswers },
      { hook: 'preredirect', middleware: submit },
    ],
  },
];
  1. One object for each waypoint, passed to configure().
  2. The template, looked for in your views folders first, then CASA's.
  3. A field, named as the form field is, with its validators, which run in order.
  4. A plain string is shown as it is. A key such as renewal:licence.errors.required would be translated.
  5. Every error the validators return is kept. The field shows the first.
  6. Only these values pass, so an altered form cannot save anything else.
  7. A regular expression, here ignoring case, so fs-123456 passes.
  8. A waypoint with no fields: there is nothing to validate, so its POST always reaches preredirect.
  9. A page hook, named without the journey. scope. It builds the rows before the page renders.
  10. On the POST from check answers: send the application, and end the session.

Write it: a date of birth waypoint#

The task: add a date of birth page to the CASA service, between the name and email pages. It uses CASA's date validator, and a rule of your own: the applicant must be 16 or over.

1. The template. CASA's date macro names the three fields dateOfBirth[dd], dateOfBirth[mm] and dateOfBirth[yyyy], so the answer is saved as one object:

casa/views/pages/date-of-birth.njkfield
{{ casaGovukDateInput({
  namePrefix: "dateOfBirth",
  casaValue: formData.dateOfBirth,
  casaErrors: formErrors,
  fieldset: {
    legend: {
      text: "What is your date of birth?",
      isPageHeading: true,
      classes: "govuk-fieldset__legend--l"
    }
  },
  hint: { text: "For example, 27 3 2007" }
}) }}

2. A validator of your own. Extend ValidatorFactory, give it a name, and return a list of errors from validate(). It leaves a date that is not real to dateObject, so that the user sees one message at a time:

depth/casa/date-of-birth.tsdepth/casa/date-of-birth.jsvalidator
// a rule CASA does not have: the applicant must be at least a given age
export class MinimumAge extends ValidatorFactory {
  name = 'minimumAge';

  validate(value: DateValue, dataContext: object) {
    const {
      years,
      errorMsg,
      today = new Date(),
    } = this.config as MinimumAgeConfig;
    const born = Date.UTC(
      Number(value?.yyyy),
      Number(value?.mm) - 1,
      Number(value?.dd),
    );
    // leave a date that is not real to the dateObject validator
    if (Number.isNaN(born)) return [];
    const latest = Date.UTC(
      today.getUTCFullYear() - years,
      today.getUTCMonth(),
      today.getUTCDate(),
    );
    return born <= latest
      ? []
      : [ValidationError.make({ errorMsg, dataContext })];
  }
}
// a rule CASA does not have: the applicant must be at least a given age
export class MinimumAge extends ValidatorFactory {
  name = 'minimumAge';

  validate(value, dataContext) {
    const {
      years,
      errorMsg,
      today = new Date(),
    } = this.config;
    const born = Date.UTC(
      Number(value?.yyyy),
      Number(value?.mm) - 1,
      Number(value?.dd),
    );
    // leave a date that is not real to the dateObject validator
    if (Number.isNaN(born)) return [];
    const latest = Date.UTC(
      today.getUTCFullYear() - years,
      today.getUTCMonth(),
      today.getUTCDate(),
    );
    return born <= latest
      ? []
      : [ValidationError.make({ errorMsg, dataContext })];
  }
}

3. The page. required catches an empty date, dateObject an impossible or future one, and MinimumAge the rest:

depth/casa/date-of-birth.tsdepth/casa/date-of-birth.jspage
const message = (text: string) => ({
  summary: text,
  inline: text,
});

export const dateOfBirthPage = {
  waypoint: 'date-of-birth',
  view: 'pages/date-of-birth.njk',
  fields: [
    field('dateOfBirth').validators([
      validators.required.make({
        errorMsg: 'Enter your date of birth',
      }),
      validators.dateObject.make({
        allowSingleDigitDay: true,
        allowSingleDigitMonth: true,
        beforeOffsetFromNow: { days: 0 },
        // dateObject needs objects here: a string throws
        errorMsg: message(
          'Date of birth must be a real date',
        ),
        errorMsgBeforeOffset: message(
          'Date of birth must be in the past',
        ),
      }),
      MinimumAge.make({
        years: 16,
        errorMsg: 'You must be 16 or over to apply',
      }),
    ]),
  ],
};
const message = (text) => ({
  summary: text,
  inline: text,
});

export const dateOfBirthPage = {
  waypoint: 'date-of-birth',
  view: 'pages/date-of-birth.njk',
  fields: [
    field('dateOfBirth').validators([
      validators.required.make({
        errorMsg: 'Enter your date of birth',
      }),
      validators.dateObject.make({
        allowSingleDigitDay: true,
        allowSingleDigitMonth: true,
        beforeOffsetFromNow: { days: 0 },
        // dateObject needs objects here: a string throws
        errorMsg: message(
          'Date of birth must be a real date',
        ),
        errorMsgBeforeOffset: message(
          'Date of birth must be in the past',
        ),
      }),
      MinimumAge.make({
        years: 16,
        errorMsg: 'You must be 16 or over to apply',
      }),
    ]),
  ],
};

4. Add it to the plan and the service. The service's plan is built by a function that takes the questions in order:

casa/app.tscasa/app.jsplan
export function buildPlan(
  questions = ['name', 'email', 'fire'],
) {
  const plan = new Plan();
  plan.addSequence(...questions);
  plan.setRoute(
    'fire',
    'fire-certificate',
    (route: PlanRoute, context: JourneyContext) =>
      context.data.fire?.fire === 'yes',
  );
  plan.setRoute(
    'fire',
    'check-answers',
    (route: PlanRoute, context: JourneyContext) =>
      context.data.fire?.fire === 'no',
  );
  plan.setRoute('fire-certificate', 'check-answers');
  return plan;
}
export function buildPlan(
  questions = ['name', 'email', 'fire'],
) {
  const plan = new Plan();
  plan.addSequence(...questions);
  plan.setRoute(
    'fire',
    'fire-certificate',
    (route, context) => context.data.fire?.fire === 'yes',
  );
  plan.setRoute(
    'fire',
    'check-answers',
    (route, context) => context.data.fire?.fire === 'no',
  );
  plan.setRoute('fire-certificate', 'check-answers');
  return plan;
}
depth/casa/date-of-birth.tsdepth/casa/date-of-birth.jsmount
export function createAppWithDateOfBirth() {
  return createApp({
    extraPages: [dateOfBirthPage],
    questions: ['name', 'date-of-birth', 'email', 'fire'],
  });
}
export function createAppWithDateOfBirth() {
  return createApp({
    extraPages: [dateOfBirthPage],
    questions: ['name', 'date-of-birth', 'email', 'fire'],
  });
}

The test walks the new page with an empty date, 31 February, a date in 2999, a 10-year-old and a real date of birth. The last one goes on to the email page.

On the job#
Find it
In a CASA service, start at the file that calls configure(). The pages hold the fields and validators, the plan the routes, and the hooks any custom work. Check which CASA major version package.json has before you trust a template's block names.
Add it
Give every validator a GOV.UK error message, dateObject as objects. Set session.secure from the environment, use a shared session store, and put pages outside the plan on the ancillary router.

End of the book