GOV.UK Stack, Fast#
Nunjucks, Express and Fastify, GOV.UK Frontend and CASA, for developers who know React and .NET
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
useStateorModelState, 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#
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.
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.
As text
- Browser to Framework: GET /name
- Framework: middleware or hooks: headers, session
- Framework to Your code: the route handler for GET /name
- Your code to Nunjucks: render pages/name.njk with the data
- Nunjucks: the template calls GOV.UK Frontend macros
- Nunjucks to Your code: HTML
- Your code to Framework: send the HTML
- Framework to Browser: 200, the page
The pieces#
| Piece | What it does | You know it as |
|---|---|---|
| Express or Fastify | the web server: routes, middleware or plugins, sessions | ASP.NET Core |
| Nunjucks | server-side templates with layouts, blocks and macros | Razor, or JSX that never reaches the browser |
| GOV.UK Frontend | the Design System's components as Nunjucks macros, with CSS and a little JavaScript | a component library |
| GOV.UK Design System | the patterns: question pages, check answers, error messages | your design system's guidance |
| Service Standard | the 14 points a government service is assessed against | a quality gate you cannot skip |
| CASA (optional) | DWP's framework that runs a whole form journey from configuration | a 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.
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.
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.nunjucks and
govuk-frontend. The rest of this book adds the plumbing around them.The request loop#
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.
As text
- Browser to Server: GET /name
- Server to Browser: 200: the page, with a form
- Browser to Server: POST /name, empty
- Server: validate: fails
- Server to Browser: 200: the same page, with errors
- Browser to Server: POST /name, Ada Lovelace
- Server: validate: passes
- Server to Session: save the answer
- Server to Browser: 302: go to /email
- 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.
As text
- Browser to Server: POST /check-answers
- Server to Browser: 302: go to /confirmation
- Browser to Server: GET /confirmation
- Server to Browser: 200: the confirmation page
- Browser: the user presses refresh
- 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.
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>
);
}<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:
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)}`,
);
});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.
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.
Express and Fastify, from zero#
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:
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,
});
});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()andFastify()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 callsnext()to pass the request on. Fastify calls it a hook, such asonRequest, which runs at a named point in every request. - A route.
app.get(path, handler)in both.:pagein the path arrives asreq.params.page, and?change=trueasreq.query.change. Fastify names the two objectsrequestandreply, where Express names themreqandres. - The answer. An Express handler calls a method on
res, such assend,json,renderorredirect, 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.
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:
createApp().listen(port, () => {
console.log(`Express version: http://localhost:${port}`);
});createApp().listen(port, () => {
console.log(`Express version: http://localhost:${port}`);
});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#
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.
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');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.
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),
});
});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),
});
});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.
type PageRequest = {
Params: { page: string };
Querystring: { change?: string };
};The same idea, three names#
| Idea | Express | Fastify | ASP.NET Core |
|---|---|---|---|
| create the app | express() | Fastify() | WebApplication.CreateBuilder() |
| add shared behaviour | app.use(fn) | app.register(plugin), app.addHook() | app.Use(...) |
| a route | router.get('/:page', fn) | app.get('/:page', fn) | MapGet, a Razor Page |
| path parameter | req.params.page | request.params.page | a route value |
| query string | req.query.change | request.query.change | a query value |
| form body | req.body | request.body | model binding |
| render a template | res.render('pages/name') | reply.view('pages/name') | return Page() |
| redirect | res.redirect('/email') | reply.redirect('/email') | RedirectToPage() |
| not found | a last app.use(fn) | setNotFoundHandler | UseStatusCodePages |
| errors | app.use((err, req, res, next) => ...) | setErrorHandler | UseExceptionHandler |
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.
As text
- onRequest: no body yet
- preParsing, then the body is parsed
- preValidation, then any schema validation
- preHandler: body and session ready
- the route handler
- preSerialization, then onSend
- onResponse: the response has gone
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.Routes, requests and responses#
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.
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`);
});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.
// 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);
});// 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,
);| Pattern | Express 5 | Fastify 5 | Gives |
|---|---|---|---|
| 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' } |
| pattern | not 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.
// 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}`);
},
);// 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.
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.
// 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);// 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',
});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.
// 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');
});// 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.
// 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']);// 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'],
});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.app.use(express.urlencoded());
app.use(express.json());
app.use(cookieParser());app.use(express.urlencoded());
app.use(express.json());
app.use(cookieParser());// 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);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,
});
});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 want | Express | Fastify | The test gets |
|---|---|---|---|
| route parameters | req.params | request.params | { page: 'name' } |
| the query string | req.query | request.query | { change: 'true', props: ['clubs', 'rings'] } |
| a form or JSON body | req.body | request.body | { fullName: 'Ada Lovelace', props: ['clubs', 'rings'] } |
| a header | req.get('host') | request.headers.host, or request.host | 127.0.0.1 and the port |
| cookies | req.cookies, with cookie-parser | request.cookies, with @fastify/cookie | { theme: 'dark' } |
| the client's IP address | req.ip | request.ip | 203.0.113.7, from X-Forwarded-For |
| the protocol | req.protocol | request.protocol | https, from X-Forwarded-Proto |
| the host name | req.hostname | request.hostname | 127.0.0.1 |
| the route's pattern | req.route.path | request.routeOptions.url | /inspect/:page |
| the URL | req.originalUrl | request.url | the path and query string, as sent |
| the session | req.session | request.session | see chapter 6 |
['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.
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' });
});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' };
});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: /');
});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: /';
});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
});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
);// 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');
});// 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');
});// 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' });
});// 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' }),
);// 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');
});// 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' },
);| Job | Express | Fastify |
|---|---|---|
| status code | res.status(201) | reply.code(201) |
| a header | res.set('Location', url) | reply.header('Location', url) |
| content type | res.type('text/plain') | reply.type('text/plain') |
| a string | res.send(text): HTML unless you set a type | return text: plain text unless you set a type |
| JSON | res.json(object) | return object |
| a template | res.render(view, data) | reply.view(view, data), with @fastify/view |
| redirect | res.redirect(url), or res.redirect(301, url) | reply.redirect(url), or reply.redirect(url, 301) |
| a cookie | res.cookie(name, value, options) | reply.setCookie(name, value, options) |
| data for every template | app.locals | defaultContext, or a Nunjucks global |
| data for this request's templates | res.locals | reply.locals |
| answer early | send, and do not call next() | reply.send(), then return reply |
res.redirect(301, url) with reply.redirect(url, 301).
Express 4 code may say res.redirect(url, 301), which Express 5 no longer
accepts.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.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.
As text
- the body is parsed from JSON or a form
- 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.
- preHandler hooks, then your handler; with attachValidation, request.validationError holds the failure
- what the handler returns is written with schema.response, which drops fields it does not list
- the response is 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 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'becomes3where the schema saysinteger. Every form field arrives as text, so this matters.useDefaults: a missingyearsbecomes0.removeAdditional: withadditionalProperties: false, fields the schema does not list are removed, not refused.
| The test sends | Fastify 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":
// 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:
// 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');
},
);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.
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,
);
},
);
};- The routes' types in one place. With
app.get<Renewal>andapp.post<Renewal>,request.params.refis a string andrequest.body.yearsa number. - A
preHandlerthat 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. - No such licence: hand the request to the not-found
handler, then return
replyso that Fastify knows the hook has answered. - Found: put the licence where every template this request renders can see it, keeping anything already there.
- Route options sit between the path and the handler.
This
preHandlerruns after validation and before the handler. - The page reads the licence from
reply.locals, so the handler passes nothing more. - Keep a validation failure for the handler, instead of answering with a 400.
- The form posts
yearsas text. Ajv converts it to a number, then checks it is 1, 3 or 5. - The body failed its schema: show the page again, with a message a person can act on.
- Fastify's built-in logger, which adds the request's id to each line. Chapter 5 covers logging.
- 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.
{% 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.
// 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:
// 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.
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;
}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');
});
};Keeping the date in the session means adding it to the session's type:
declare module 'express-session' {
interface SessionData {
dateOfBirth: DateParts;
}
}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.
// 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()]);
}// 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;
}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.path: '/' on every Fastify cookie.Structure, errors, logging and tests#
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:
| File | What it holds | In this book |
|---|---|---|
| app | builds the app: settings, middleware or plugins, routes and error pages; it never listens | express/app.ts, fastify/app.ts |
| server | reads the configuration, calls the app's function and listens on a port | express/server.ts, fastify/server.ts |
| routes | the routes, grouped in a router or a plugin | express/routes.ts, fastify/routes.ts |
| views | the Nunjucks templates | views/ |
| shared code | rules that do not depend on the framework, such as validation | shared/ |
| tests | tests that build the app and send it requests | test/ |
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:
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.
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.
// 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 },
);
});// 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.
// 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);// 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:
// 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 requests | Both frameworks answer |
|---|---|
GET /sync | 500 and "Sorry, there is a problem with the service" |
GET /async | 500 and the same page: the rejected promise reached the handler |
POST /renew | 409 and "This licence has already been renewed", from the error's status |
GET /nowhere | 404 and "Page not found", from the not-found handler |
GET /api/licences/JL-0042 | in Fastify, 502 and JSON from the plugin's own handler |
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.
// 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),
);// 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 } });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');
});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';
});| Line | Express, with pino-http | Fastify |
|---|---|---|
| when the request arrives | none | "incoming request", with the request's reqId |
| your own line | req.log.info(...): the request is attached under req, with its id | request.log.info(...), with reqId |
| when the request ends | "request completed", with res.statusCode | "request completed", with res.statusCode |
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.
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.
// 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'));
});// 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;
});// 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 learns about each decorator through declaration merging, as it does for the session:
// 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.
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:
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}`,
);
};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 need | Express | Fastify |
|---|---|---|
| a request | Request<Params, ResBody, ReqBody, Query> | FastifyRequest<{ Params, Querystring, Body }> |
| a response | Response | FastifyReply |
| a handler | RequestHandler<Params, ResBody, ReqBody, Query> | RouteHandler<{ Params, Querystring, Body }> |
| an error handler | ErrorRequestHandler | the function you pass to setErrorHandler |
| a group of routes | Router | FastifyPluginAsync<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.
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();
}
});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('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:
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 code | What it did | In Express 5 |
|---|---|---|
app.get('/old/*', ...) and req.params[0] | an unnamed wildcard | a 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 second | res.redirect(301, url) |
res.send(404) | a bare status | res.sendStatus(404) |
res.json(obj, 201) | the status second | res.status(201).json(obj) |
req.param('name') | the value from the path, body or query | req.params, req.body or req.query |
app.del() | a DELETE route | app.delete() |
a wrap() around async handlers | passing a rejection to next | not needed |
req.body with no parser | {} | undefined |
express.static | served dotfiles | ignores 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:
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 code | What it did | In Fastify 5 |
|---|---|---|
reply.redirect(301, url) | the status first | reply.redirect(url, 301) |
querystring: { page: { type: 'integer' } } | a short-hand schema | a full schema, with type: 'object' and properties |
request.routerPath, request.routeConfig | the route's definition | request.routeOptions.url, request.routeOptions.config |
decorateRequest('user', {}) | one object, shared by every request | refused: declare it with null, and set it in an onRequest hook |
request.hostname | the host name and the port | request.host; hostname has no port |
listen(3000) | a port | listen({ port: 3000 }) |
logger: pino() | your own pino instance | loggerInstance: pino() |
reply.getResponseTime() | the time taken | reply.elapsedTime |
an async plugin that also calls done | allowed | an 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.
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');
});- Form bodies, through the
body-parser package. Express 5 code calls
express.urlencoded()instead. - 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. - A status on its own, with its text as the body: here, "Not Found".
- Back to the page in the Referer header, or to
/. Express 5 removed'back'. - An optional parameter, marked with
?. Express 5 writes/search{/:term}. - An unnamed wildcard, read as
req.params[0]. Express 5 needs a name. - 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.
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;
}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:
export function createAppWithHealth() {
return createApp([healthRoutes('1.2.3')]);
}export function createAppWithHealth() {
return createApp([healthRoutes('1.2.3')]);
}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.
package.json before you trust an example.The plumbing#
Every GOV.UK service needs the same plumbing before its first page works. Here it is in order, in both frameworks, with the traps.
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.
| Job | Express | Fastify |
|---|---|---|
| security headers and the CSP nonce | helmet | @fastify/helmet |
| GOV.UK Frontend's files | express.static, built in | @fastify/static |
| form bodies | express.urlencoded(), built in | @fastify/formbody |
| cookies | handled inside express-session | @fastify/cookie |
| sessions | express-session | @fastify/session |
| CSRF tokens | csrf-sync | @fastify/csrf-protection |
| templates | Nunjucks, 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.
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.
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',
},
}),
);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',
},
});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.Tell each framework what the session holds, with declaration merging. Then
answers and reference are typed wherever you touch the
session.
declare module 'express-session' {
interface SessionData {
answers: Answers;
reference: string;
}
}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.
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();
});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);
});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.
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.',
});
});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.',
});
});express-session or @fastify/session and check which
store production uses. Then check that the CSRF check reads the token from the form
body.Nunjucks for JSX and Razor developers#
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:
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'],
});<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, <Ada></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 write | What it does | Example |
|---|---|---|
{{ 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#
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>
</>
);
}{% 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.
@* _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>{# 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 %}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 write | In Nunjucks | Note |
|---|---|---|
{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 helper | a macro call | {{ govukInput({...}) }} instead of <input asp-for> |
useState, HttpContext.Session | req.session, on the server | the routes write it; the template only reads what they pass |
ModelState errors | errorList and fieldErrors | built 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.
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:
{% set panelHtml %}
Your reference number<br><strong>{{ reference }}</strong>
{% endset %}
{{ govukPanel({ titleText: title, html: panelHtml }) }}@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.nunjucks.configure() or to
@fastify/view. Every template name, such as govuk/template.njk, is
looked up in those folders, in order.layout.njk that extends govuk/template.njk in your views
folder, and make every page extend it.Nunjucks in depth#
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:
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 },
);| Setting | Default | What it does |
|---|---|---|
autoescape | true | escapes every output; leave it on |
throwOnUndefined | false | fails when a template prints an undefined value, instead of printing nothing |
trimBlocks | false | removes the line break after each tag |
lstripBlocks | false | removes the spaces before a tag at the start of a line |
noCache | false | compiles every template again on every render |
watch | false | reloads templates that change on disk; needs the chokidar package |
express | none | makes 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:
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".
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:
{{ "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 write | It means |
|---|---|
a ~ b | joins 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, not | logic, where JavaScript writes &&, || and ! |
x in list | true when a list holds x, a string contains x, or an object has the key x |
a if test else b | an 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.postcode | a lookup: anything undefined along the way prints nothing, with no error |
none, true, false | JavaScript's null, true and false |
{{ "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.
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.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.| Filter | What it does | Example | Output |
|---|---|---|---|
abs | The absolute value of a number | {{ -3 | abs }} | 3 |
batch | Splits 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; |
capitalize | Upper-cases the first letter, and lower-cases the rest | {{ "cLUBS" | capitalize }} | Clubs |
center | Pads a string with spaces to a width, centred | [{{ "hi" | center(6) }}] | [ hi ] |
default | A 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] |
d | Short for default | {{ "" | d("Not provided", true) }} | Not provided |
dictsort | An 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 |
dump | The value as JSON. Its quotes are then escaped, like any output | {{ {"a": 1} | dump }} | {"a":1} |
escape | Escapes HTML, and marks the result safe so that it is not escaped twice | {{ "<b>" | escape }} | <b> |
e | Short for escape | {{ "a & b" | e }} | a & b |
first | The first item of a list | {{ ["clubs", "rings"] | first }} | clubs |
float | Converts to a decimal number | {{ "3.5" | float + 1 }} | 4.5 |
forceescape | Escapes HTML even when the value is marked safe | {{ "<b>" | safe | forceescape }} | <b> |
groupby | Groups 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; |
indent | Indents every line after the first | {{ "a\nb" | indent(2) }} | a
b |
int | Converts to a whole number, dropping any fraction | {{ "42.9" | int }} | 42 |
join | Joins a list into a string. A second argument joins one attribute of each object | {{ ["clubs", "rings"] | join(", ") }} | clubs, rings |
last | The last item of a list | {{ ["clubs", "rings"] | last }} | rings |
length | The number of items in a list, or of characters in a string; 0 for an undefined value | {{ ["clubs", "rings"] | length }} | 2 |
list | Turns a string into a list of its characters | {{ "abc" | list | join("-") }} | a-b-c |
lower | Lower-cases a string | {{ "RINGS" | lower }} | rings |
nl2br | Turns 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 |
random | One item from a list, at random | {{ ["only"] | random }} | only |
reject | Removes the items that pass a test | {{ [1, 2, 3, 4] | reject("odd") | join(",") }} | 2,4 |
rejectattr | Removes the objects whose attribute is truthy | {{ [{"n": "Ada", "done": true}, {"n": "Bo", "done": false}] | rejectattr("done") | join(",", "n") }} | Bo |
replace | Replaces every match of a string, or of a regular expression, inside a string | {{ "FS-123-456" | replace("-", "") }} | FS123456 |
reverse | Reverses a list or a string | {{ [1, 2, 3] | reverse | join(",") }} | 3,2,1 |
round | Rounds 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 |
safe | Marks a string safe, so that it is not escaped. Use it only for HTML you built | {{ "<b>bold</b>" | safe }} | <b>bold</b> |
select | Keeps the items that pass a test | {{ [1, 2, 3, 4] | select("even") | join(",") }} | 2,4 |
selectattr | Keeps the objects whose attribute is truthy | {{ [{"n": "Ada", "done": true}, {"n": "Bo", "done": false}] | selectattr("done") | join(",", "n") }} | Ada |
slice | Splits 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; |
sort | Sorts a list; sort(false, false, "name") sorts objects by one attribute | {{ [3, 1, 2] | sort | join(",") }} | 1,2,3 |
string | Converts to a string | {{ 42 | string | length }} | 2 |
striptags | Removes HTML tags, and collapses the spaces left behind | {{ "<p>Hello <b>Ada</b></p>" | striptags }} | Hello Ada |
sum | Adds up a list of numbers | {{ [1, 2, 3] | sum }} | 6 |
title | Capitalises every word | {{ "apply for a licence" | title }} | Apply For A Licence |
trim | Removes spaces from both ends | [{{ " Ada " | trim }}] | [Ada] |
truncate | Cuts a string to a length, at a word boundary, and adds ... | {{ "Apply for a juggling licence" | truncate(12) }} | Apply for a... |
upper | Upper-cases a string | {{ "clubs" | upper }} | CLUBS |
urlencode | Encodes a string for use in a URL | {{ "a b&c" | urlencode }} | a%20b%26c |
urlize | Turns 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> |
wordcount | Counts the words in a string | {{ "one two three" | wordcount }} | 3 |
| Test | What it does | Example | Output |
|---|---|---|---|
callable | The value is a function | {{ range is callable }} | true |
defined | The value is not undefined | {{ missing is defined }} | false |
divisibleby | The number divides by another with no remainder | {{ 9 is divisibleby(3) }} | true |
escaped | The value is marked safe | {{ "<b>" | safe is escaped }} | true |
equalto | The value is identical to another, as with === | {{ 2 is equalto(2) }} | true |
eq | Short for equalto, so a string never equals a number | {{ "2" is eq(2) }} | false |
sameas | Another name for equalto | {{ 2 is sameas(2) }} | true |
even | The number is even | {{ 4 is even }} | true |
falsy | The value is falsy | {{ "" is falsy }} | true |
ge | Greater than, or equal to, another | {{ 3 is ge(3) }} | true |
greaterthan | Greater than another | {{ 3 is greaterthan(2) }} | true |
gt | Short for greaterthan | {{ 3 is gt(2) }} | true |
le | Less than, or equal to, another | {{ 2 is le(3) }} | true |
lessthan | Less than another | {{ 2 is lessthan(3) }} | true |
lt | Short for lessthan | {{ 2 is lt(3) }} | true |
lower | The string is all lower case | {{ "abc" is lower }} | true |
ne | Not identical to another, as with !== | {{ 2 is ne(3) }} | true |
null | The value is null, written none in a template | {{ none is null }} | true |
number | The value is a number: a form value never is | {{ "5" is number }} | false |
odd | The number is odd | {{ 3 is odd }} | true |
string | The value is a string | {{ "5" is string }} | true |
truthy | The value is truthy | {{ "x" is truthy }} | true |
undefined | The value is undefined | {{ missing is undefined }} | true |
upper | The string is all upper case | {{ "ABC" is upper }} | true |
iterable | The value can be looped over | {{ [1] is iterable }} | true |
mapping | The value is an object | {{ {"a": 1} is mapping }} | true |
| Global | What it does | Example | Output |
|---|---|---|---|
range | A 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 |
cycler | Gives 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 |
joiner | Gives 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:
{% 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.
| Variable | Gives |
|---|---|
loop.index, loop.index0 | the position, counting from 1 or from 0 |
loop.revindex, loop.revindex0 | the items left, counting down to 1 or to 0 |
loop.first, loop.last | true on the first, or the last, time round |
loop.length | the 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:
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.
{% 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.
{% 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.
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:
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>{% 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 %}{% 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:
<title>{% block title %}{{ serviceName }}{% endblock %}</title>
<main>{% block content %}<p>No content yet.</p>{% endblock %}</main>{% 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.
// 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:
1. {{ comment }}
2. {{ comment | escape | nl2br | safe }}
{% set message %}<b>{{ name }}</b>{% endset %}
3. {{ message }}
4. {{ message | safe }}
5. {{ tag }}// 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:
<i>one</i>: the value is escaped.<i>one</i><br />: escaped first, then only the line break becomes a tag.<b>&lt;Ada&gt;</b>: a set block is escaped again when printed, so its own tags show, and the name is escaped twice.<b><Ada></b>: the block marked safe. Its own HTML stays, and the name is escaped once.<strong class="govuk-tag">Approved</strong>: HTML built in JavaScript and marked safe there.
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#
| Message | What causes it |
|---|---|
template not found: nope.njk | no search folder has that name: check the folders and the spelling |
filter not found: nope | a misspelt filter, or a custom one not added to this environment |
Unable to call nope, which is undefined or falsey | a macro that was not imported, or a misspelt global or function |
unexpected end of file | a tag opened and never closed, such as a for with no endfor |
unknown block tag: endfor | an end tag that does not match the tag it closes; the message gives the line and column |
attempted to output null or undefined value | with 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.
{# 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 %}- The parent: GOV.UK Frontend's page template, found in the first search folder.
- Imports one macro from another file. It sees only its arguments and the globals.
- Fills the parent's
pageTitleblock, which becomes the page's<title>. - An inline if with no else:
"Error: " only when there are errors.
titleis set at the top of each page, and reaches this block throughextends. The minus trims the whitespace before the tag. - The minus trims the line break before the tag, so the title has no stray spaces.
- The parent's
headblock, where the service adds its stylesheet. - A route passes
backLinkonly when the page has somewhere to go back to. - A GOV.UK Frontend macro: one options object in, its HTML out.
- A new block, inside the parent's
contentblock, for each page to fill. - The script's nonce, printed into an attribute
and escaped like any output. The service sets
cspNoncefor 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 |:
// 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:
{% 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:
{% 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.
addFilter and addGlobal to find the service's own filters and
globals.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#
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.
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.
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.
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'),
};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));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:
{% block head %}
<link rel="stylesheet" href="/govuk/govuk-frontend.min.css">
{% 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 %}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.
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}'`,
],
},
},
}),
);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.
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:
{{ 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:
| Option | What it does |
|---|---|
text, html | the content: text is escaped, html is not, and html wins when both are set; some pairs have a prefix, such as titleText and titleHtml |
classes | extra classes, added after the component's own |
attributes | extra HTML attributes, as an object; each template prints them with GOV.UK Frontend's govukAttributes macro, which escapes the values |
id, name | the element's id and the form field's name; radios and checkboxes build their items' ids from idPrefix |
items | the 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.legend | objects with their own text, html and classes; isPageHeading: true makes a label or legend the page's heading |
content between call tags | some 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:
{% macro appLicenceCard(params) %}
{%- include "./template.njk" -%}
{% endmacro %}{% 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:
{% 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.
@use instead of @import:
@use "node_modules/govuk-frontend/dist/govuk" as *;govuk-frontend version in package.json. Version 6
code uses Sass @use; version 5 code uses @import. Then find where
/assets is served.dist/govuk/assets at /assets, load the CSS and the module
script, and pass cspNonce to every page.Journeys: one thing per page#
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.
As text
The journey starts at name.
name, thenemail.email, thenfire.fireasks a question. Yes:fire-certificate. No:check-answers.fire-certificate, thencheck-answers.check-answers, thenconfirmation.confirmationis 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.
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.
// 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.
As text
- Browser to Server: GET /fire?change=true
- Server to Browser: the fire page; Back goes to check answers
- Browser to Server: POST /fire?change=true, yes
- Server to Session: save: fire is yes
- Server: fire-certificate is not answered yet
- Server to Browser: 302: go to /fire-certificate
- Browser to Server: POST /fire-certificate, FS-123456
- Server to Browser: 302: back to /check-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');
});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.
questions
here, or a CASA plan. It answers the question "why did Continue send me there?".Validation and errors, the GOV.UK way#
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.
As text
- Browser to Server: POST /name, empty
- Server: validate: one error
- Server to Browser: 200: same page, title starts Error:
- Browser: focus moves to the error summary
- Browser: the user follows the link to the field
- Browser to Server: POST /name, Ada Lovelace
- Server: validate: no errors
- 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 addrequiredto inputs. - Start the page title with "Error: ", so screen readers announce it first.
As text
How an error connects on a GOV.UK page. The browser tab title starts with "Error:", so screen readers hear it first. The error summary sits at the top of the main content, below the back link and above the heading, and GOV.UK Frontend's JavaScript moves focus to it when the page loads. Each item in the summary is a link whose href is the field's id, here #fullName, so following it moves to the input with id fullName. The field shows the same message, worded identically, in its error message, and keeps what the user typed.
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.
// 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:
{% block pageTitle %}
{{- "Error: " if errorList }}{{ title }} – {{ serviceName }} – GOV.UK
{%- endblock %}{% if errorList %}
{{ govukErrorSummary({
titleText: "There is a problem",
errorList: errorList
}) }}
{% endif %}<label asp-for="FullName">What is your full name?</label>
<span asp-validation-for="FullName"></span>
<input asp-for="FullName" />{{ 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#
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.
attachValidation: true. Fastify
then puts the error on request.validationError and lets your handler render the
page.govukErrorSummary, and a message for
each field's errorMessage. Check that the title starts with "Error: " when there
are errors.novalidate on every form.The standards you will be assessed on#
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.
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#
| Point | What it asks | What you do |
|---|---|---|
| 5: make sure everyone can use the service | meet WCAG 2.2 AA, and work with assistive technology | use the Design System's components, test with a keyboard and a screen reader |
| 9: create a secure service | protect users' data and privacy | sessions, CSRF tokens, security headers, no secrets in code |
| 11: choose the right tools and technology | tools you can justify | explain why you chose each framework and package |
| 12: make new source code open | publish the code | keep secrets and configuration out of the repository |
| 13: use common standards, components and patterns | reuse what exists | GOV.UK Frontend's macros and the Design System's patterns |
| 14: operate a reliable service | keep it running and recoverable | health checks, monitoring and clear error pages |
Read the points themselves:
- Service Standard point 5: make sure everyone can use the service
- Service Standard point 9: create a secure service which protects users' privacy
- Service Standard point 11: choose the right tools and technology
- Service Standard point 12: make new source code open
- Service Standard point 13: use and contribute to open standards, common components and patterns
- Service Standard point 14: operate a reliable service
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.
As text
- Can the service work without this cookie? No: strictly necessary: no consent needed. Yes: the next step.
- it is not strictly necessary, such as analytics
- ask first, with a cookie banner
- set it only after the user agrees
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#
Two checklists built from every chapter's "On the job" box, and two tables for the code you will inherit.
Decode an existing repository#
- OpenChapter 01: The stack on one page
package.json.expressorfastifynames the server, thegovuk-frontendversion names the components, and@dwp/govuk-casameans the journey is configured rather than written by hand. - 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
- Find where the app is created:Chapter 03: Express and Fastify, from zero
express()orFastify(). Read down from there. In Express, the order ofapp.usecalls is the order a request runs. In Fastify, read eachregisterandaddHook. - List the routes: search forChapter 04: Routes, requests and responses
Router(,app.use(and.get(in Express, andapp.register(andprefixin Fastify. Each mount path or prefix is added to the start of every route inside 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 inChapter 05: Structure, errors, logging and tests
package.jsonbefore you trust an example. - Search forChapter 06: The plumbing
express-sessionor@fastify/sessionand check which store production uses. Then check that the CSRF check reads the token from the form body. - Find the folder list passed toChapter 07: Nunjucks for JSX and Razor developers
nunjucks.configure()or to@fastify/view. Every template name, such asgovuk/template.njk, is looked up in those folders, in order. - 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 forChapter 08: Nunjucks in depth
addFilterandaddGlobalto find the service's own filters and globals. - Check theChapter 09: GOV.UK Frontend
govuk-frontendversion inpackage.json. Version 6 code uses Sass@use; version 5 code uses@import. Then find where/assetsis served. - Find the journey's map: a next-page function or table, likeChapter 10: Journeys: one thing per page
questionshere, or a CASA plan. It answers the question "why did Continue send me there?". - Find where errors are built: a list forChapter 11: Validation and errors, the GOV.UK way
govukErrorSummary, and a message for each field'serrorMessage. Check that the title starts with "Error: " when there are errors. - 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
- In a CASA app, open the file that callsChapter 15: CASA
configure(). The pages hold the fields and validators, thePlanholds the routes, and the views folder holds the templates. - In a CASA service, start at the file that callsChapter 16: CASA in depth
configure(). The pages hold the fields and validators, the plan the routes, and the hooks any custom work. Check which CASA major versionpackage.jsonhas before you trust a template's block names.
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().
As text
- the address, such as /fire
- the GET and POST routes that match it
- the template the GET renders
- the macros that template calls
- the session keys the POST writes
- the next-page rule it follows
What package.json tells you#
| You see | It means |
|---|---|
express | an Express app: read the app.use calls in order |
express 4.x | Express 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.x | Fastify 4: reply.redirect(code, url) and short-hand schemas; see chapter 5 |
@dwp/govuk-casa | a CASA journey: pages, fields and a plan, in the file that calls configure() |
@dwp/govuk-casa 9.x | CASA 9, on Express 4, with older layout block names; see chapter 16 |
govuk-frontend 5.x | version 5: Sass @import, and a rebrand option on the header and footer |
govuk-frontend 6.x | version 6: Sass @use, and no rebrand option |
govuk-prototype-kit | the GOV.UK Prototype Kit: a prototype, not the production service |
express-session, @fastify/session | server-side sessions: check the production store |
Start a new service#
- Start from three packages: a server (Express or Fastify),Chapter 01: The stack on one page
nunjucksandgovuk-frontend. The rest of this book adds the plumbing around them. - 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
- Choose one framework per service. CASA needs Express; Fastify gives you typed routes, schemas and scoped plugins.Chapter 03: Express and Fastify, from zero
- Give each group of pages its own router or plugin at one prefix. Trust the proxy by its addresses, and setChapter 04: Routes, requests and responses
path: '/'on every Fastify cookie. - 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
- 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
- Put aChapter 07: Nunjucks for JSX and Razor developers
layout.njkthat extendsgovuk/template.njkin your views folder, and make every page extend it. - Turn onChapter 08: Nunjucks in depth
throwOnUndefinedin 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. - ServeChapter 09: GOV.UK Frontend
dist/govuk/assetsat/assets, load the CSS and the module script, and passcspNonceto every page. - 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
- Validate on the server when the user presses Continue, keep what they typed, link every summary item to its field, and putChapter 11: Validation and errors, the GOV.UK way
novalidateon every form. - 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
- Start fromChapter 15: CASA
configure()with a plan and pages, mount it on an Express app, and add your confirmation page as an ancillary route. - Give every validator a GOV.UK error message,Chapter 16: CASA in depth
dateObjectas objects. Setsession.securefrom the environment, use a shared session store, and put pages outside the plan on the ancillary router.
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 code | What changed | Where it bites |
|---|---|---|
| Express 4 | wildcards need a name, /*splat; optional parts use braces | old route paths stop matching |
| Express 4 | rejected promises now reach the error handler | old code wraps async handlers by hand |
| Express 4 | req.body is undefined without a parser; urlencoded defaults to extended: false | nested form fields |
| Express 4 | res.redirect('back') is gone: use req.get('Referrer') || '/' | back links |
| Fastify 4 | reply.redirect(code, url) became reply.redirect(url, code) | every redirect |
| Fastify 4 | schemas need type: 'object' and properties; request decorators cannot hold an object | start-up errors |
| GOV.UK Frontend 5 | Sass moved from @import to @use, with Dart Sass 1.79 or later | your Sass entry file |
| GOV.UK Frontend 5 | the header and footer lost their rebrand option | layout templates |
| CASA 9 | CASA 10 moved from Express 4 and GOV.UK Frontend 5 to Express 5 and GOV.UK Frontend 6, and needs Node 22 | upgrade all three together |
| CASA 9 | layout blocks beforeContent, skipLink and header were renamed | overridden blocks stop showing, with no error |
| CommonJS | require() became import, and __dirname became import.meta.dirname | the top of every file |
Quick reference#
The book on one page to print, a quick reference for each tool, and where to look when the book stops.
| Step | What the code does |
|---|---|
| GET | render the template with the saved answer |
| POST | pick the page's fields, then validate them |
| errors | render the same template with errorList and fieldErrors |
| no errors | save to the session, then redirect with a 302 |
| typed address | redirect to the first page not yet answered |
| Step | Express | Fastify |
|---|---|---|
| headers | helmet | @fastify/helmet |
| files | express.static | @fastify/static |
| bodies | express.urlencoded() | @fastify/formbody |
| session | express-session | @fastify/session |
| CSRF | csrf-sync | @fastify/csrf-protection |
| To | Write |
|---|---|
| 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 |
| Rule | Detail |
|---|---|
| where | on the server, when the user presses Continue |
| answers | shown again, as the user typed them |
| summary | top of the page, one link per field |
| field | the same words, through errorMessage |
| title | starts with Error: |
| forms | novalidate, and no required |
Express 5#
| You want | Write | Taught in |
|---|---|---|
| the app | const app = express() | chapter 3 |
| a setting | app.set('trust proxy', ['loopback', '10.0.0.0/8']), app.set('view engine', 'njk') | chapter 4 |
| middleware | app.use(fn), or app.use('/path', fn); call next() to go on | chapter 3 |
| a route | app.get(path, ...handlers), and post, put, delete, all, app.route(path) | chapter 4 |
| path syntax | /:id, /search{/:term}, /docs/*path | chapter 4 |
| a group of routes | Router({ mergeParams: true }), then app.use('/prefix', router) | chapter 4 |
| the request | req.params, req.query, req.body, req.get(name), req.cookies, req.session, req.ip, req.originalUrl | chapter 4 |
| the response | res.status(), res.set(), res.type(), res.send(), res.json(), res.render(), res.redirect([status,] url), res.cookie(), res.locals | chapter 4 |
| skip to the next route | next('route') | chapter 4 |
| errors | throw, or reject; an error handler (err, req, res, next) added last | chapter 5 |
| not found | a last app.use((req, res) => ...) | chapter 5 |
| request logs | app.use(pinoHttp()), then req.log.info() | chapter 5 |
| start | app.listen(port) | chapter 3 |
Fastify 5#
| You want | Write | Taught in |
|---|---|---|
| the app | Fastify({ logger, trustProxy }) | chapter 3 |
| a plugin | await app.register(plugin, { prefix }); share it with fastify-plugin | chapter 5 |
| a route | app.get(path, [options], handler), or app.route({ method, url, handler }) | chapter 4 |
| route options | schema with body, querystring, params and response; attachValidation; preHandler; logLevel | chapter 4 |
| path syntax | /:id, /search/:term?, /docs/*, /years/:year(^\\d{4}$) | chapter 4 |
| a decorator | app.decorate(), app.decorateReply(), app.decorateRequest(name, null) with an onRequest hook | chapter 5 |
| the request | request.params, request.query, request.body, request.headers, request.cookies, request.session, request.ip, request.url, request.log | chapter 4 |
| the reply | reply.code(), reply.header(), reply.type(), return value, reply.view(), reply.redirect(url, [status]), reply.setCookie(), reply.locals | chapter 4 |
| hooks, in order | onRequest, preParsing, preValidation, preHandler, the handler, preSerialization, onSend, onResponse | chapter 3 |
| errors | throw; app.setErrorHandler(), which covers its own plugin | chapter 5 |
| not found | app.setNotFoundHandler() | chapter 5 |
| tests | await app.inject({ method, url }) | chapter 5 |
| start | await app.listen({ port, host }) | chapter 3 |
Nunjucks#
| You want | Write |
|---|---|
| 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 want | Write | Taught in |
|---|---|---|
| to set up | configure({ views, session: { secret, secure }, pages, plan }), then mount(app) | chapter 16 |
| a page | { waypoint, view, fields, hooks } | chapter 15 |
| a field | field(name, { optional }), .validators([...]), .processors([...]), .if(condition) | chapter 16 |
| a validator | validators.required.make({ errorMsg }), and nine more | chapter 16 |
| a validator of your own | a class that extends ValidatorFactory, with name and validate() | chapter 16 |
| the plan | new Plan(), addSequence(), setRoute(from, to, condition), addSkippables() | chapter 16 |
| hooks, in order | GET: presteer, poststeer, prerender; POST: presteer to postvalidate, then preredirect or prerender | chapter 16 |
| answers in code | req.casa.journeyContext.data | chapter 16 |
| answers in a template | formData, formErrors | chapter 16 |
| a change link | waypointUrl({ waypoint, edit: true, editOrigin }) | chapter 16 |
| a page outside the plan | ancillaryRouter.get(path, handler) | chapter 16 |
| translate | t('namespace:key'), and ?lang=cy to switch | chapter 16 |
| end the session | endSession(req, callback), then redirect | chapter 16 |
Where the answer lives#
| Question | Look here |
|---|---|
| every option of a component's macro | the component's page on the Design System site, Nunjucks tab |
| how a pattern should behave | the Design System's patterns |
| what a Service Standard point asks | the Service Manual |
| installing and serving GOV.UK Frontend | GOV.UK Frontend's documentation |
| CASA's API | CASA's repository and examples, and the type definitions in its npm package |
| an Express method | the Express 5.x API reference |
| a Fastify method or hook | the Reference section of the Fastify documentation |
- GOV.UK Design System
- GOV.UK Frontend documentation
- Service Manual
- Express 5.x API reference
- Fastify reference
Libraries used in this book#
| Library | What it does here |
|---|---|
| Express | the web server: an ordered chain of middleware, then routes |
| Fastify | the other web server: plugins, hooks and routes |
| Nunjucks | the template language GOV.UK Frontend's macros are written in |
| GOV.UK Frontend | the page template, the component macros, and their CSS and JavaScript |
| CASA | DWP's form-journey framework, on Express; the npm package is @dwp/govuk-casa |
helmet | security headers, including the CSP nonce, for Express |
express-session | server-side sessions for Express |
csrf-sync | CSRF tokens for Express forms |
@fastify/view | renders Nunjucks templates in Fastify |
@fastify/static | serves GOV.UK Frontend's files in Fastify |
@fastify/formbody | parses form posts in Fastify |
@fastify/cookie | cookies for Fastify; @fastify/session needs it |
@fastify/session | server-side sessions for Fastify |
@fastify/csrf-protection | CSRF tokens for Fastify forms |
@fastify/helmet | security headers and CSP nonces for Fastify |
fastify-plugin | shares a Fastify plugin with the whole app |
cookie-parser | reads cookies into req.cookies for Express |
| Ajv | the JSON Schema validator behind Fastify's route schemas |
pino | the JSON logger inside Fastify, and behind pino-http |
pino-http | request logging with pino for Express |
supertest | sends requests to an Express app in tests |
| TypeScript | the example app's language, with erasableSyntaxOnly |
| GOV.UK Prototype Kit | for prototypes, not production services |
CASA OptionalExpress only#
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.
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.
The plan and the pages#
As text
The journey starts at name.
name, thenemail.email, thenfire.fireasks a question. Yes:fire-certificate. No:check-answers.fire-certificate, thencheck-answers.check-answersis the final page.
A route that branches carries a condition that reads the answers so far:
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:
{
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',
}),
]),
],
},{{ 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
}) }}As text
- steer: 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? Yes: show the page with errors (prerender). No: the next step.
- 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.
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'));
});
};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,
});const app = express();
mount(app, { serveFirstWaypoint: true });const app = express();
mount(app, { serveFirstWaypoint: true });CASA and the hand-rolled version#
| Job | Hand-rolled, in Express or Fastify | CASA |
|---|---|---|
| the journey | questions and next() in journey.ts | a Plan, with setRoute conditions |
| a page | a GET route and a POST route | a page object: waypoint, view and fields |
| validation | validators.ts, called in the POST | field(...).validators([...]) |
| no skipping ahead | route(), checked in the GET | built in |
| sessions, CSRF, security headers | the plumbing in chapter 6 | built in, set up by configure() |
| errors in templates | errorList and fieldErrors | formErrors, and the casaGovuk* macros |
| Change links | ?change=true | ?edit=true&editorigin=... |
| the "Error: " title | the layout adds it | casaPageTitle adds it |
| field ids | the field's name, such as fullName | f- and the name, such as f-fullName |
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".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.
configure(). The pages hold the
fields and validators, the Plan holds the routes, and the views folder holds the
templates.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#
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:
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,
},
],
});| Option | Default | What it does |
|---|---|---|
views | none | your template folders, searched before CASA's own and GOV.UK Frontend's |
session.secret | none: required | signs the session cookie; configure() throws without it |
session.secure | none: required | true sends the cookie over HTTPS only; configure() throws unless you set it |
session.name | casa-session | the cookie's name |
session.ttl | 3600 | seconds of inactivity before the session expires |
session.store | memory, with a warning | where sessions live; use a store every copy of the service shares, such as Redis |
session.cookieSameSite, session.cookiePath | Strict, / | the cookie's SameSite and Path attributes |
pages, plan | none | the waypoints' templates and fields, and the routes between them |
hooks | none | middleware at named points, for every waypoint or for one path |
i18n | locales en and cy | translation folders, the languages, and a fallbackLng for missing text |
events | none | functions that run when the journey context changes |
plugins | none | packages that change the configuration, or the routers |
mountUrl | the path you mount on | the URL prefix when a proxy rewrites paths; it needs a trailing slash |
errorVisibility | on submit | whether a page's errors show again on a plain GET |
helmetConfigurator | none | a function that changes CASA's security headers |
formMaxParams, formMaxBytes | 25 fields, 50 KB | limits on a form body |
contextIdGenerator | UUIDs | how 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 returns | What it is |
|---|---|
staticRouter, ancillaryRouter, journeyRouter | routers for CASA's assets, for pages outside the plan, and for the waypoints |
preMiddleware, postMiddleware | what runs first, such as the security headers, and last: the 404 and error pages |
sessionMiddleware, cookieParserMiddleware | the session and its signed cookie |
i18nMiddleware, bodyParserMiddleware, dataMiddleware | translations, form bodies, and req.casa |
csrfMiddleware | CSRF protection, for forms you add yourself |
nunjucksEnv | the template environment, to add filters or share with another app |
mount(app, { route, serveFirstWaypoint }) | adds all of it to an Express app |
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:
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.
tidyturns" jl-0042 "intoJL-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.
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.
| Validator | It checks | Options, besides errorMsg |
|---|---|---|
required | the value is not empty | none |
email | an email address | none |
inArray | the value, or every item of a list, is in source | source |
regex | the value matches pattern, or with invert, does not | pattern, invert |
strlen | the length of the text | min, max, errorMsgMin, errorMsgMax |
wordCount | the number of words | min, max, errorMsgMin, errorMsgMax |
range | a number between limits | min, max, errorMsgMin, errorMsgMax, errorMsgInvalid |
dateObject | a real date, from day, month and year fields | allowSingleDigitDay, allowSingleDigitMonth, allowMonthNames, afterOffsetFromNow, beforeOffsetFromNow, and their messages |
nino | a National Insurance number | allowWhitespace |
postalAddressObject | an address, from separate fields | requiredFields, strlenmax, and a message for each part |
errorMsg, required says "Information is required". Always pass
your own, in the Design System's wording.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:
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:
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 to | Write |
|---|---|
| read one page's answers | context.getDataForPage('fire'), or context.data.fire |
| change them | context.setDataForPage('fire', { fire: 'no' }), then JourneyContext.putContext(req.session, context) |
| read a page's errors | context.getValidationErrorsForPage('fire') |
| know whether a page passed | context.isPageValid('fire') |
| remove old answers | context.purge(['fire-certificate']) |
| keep a second set of answers | JourneyContext.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:
As text
- presteer hooks
- may the user be on this page yet? No: redirect them to the page they should be on. Yes: the next step.
- poststeer hooks
- prerender hooks: add data for the template
- 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:
| Hook | Runs on | When |
|---|---|---|
presteer, poststeer | GET and POST | around the check that stops the user skipping ahead |
presanitise, postsanitise | POST | around tidying the body: unlisted fields dropped, processors run |
pregather, postgather | POST | around saving the answers in the journey context |
prevalidate, postvalidate | POST | around validation |
preredirect | POST, when the page is valid | before the redirect to the next waypoint |
prerender | GET, and a POST with errors | before the page is rendered |
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:
| Value | What it holds |
|---|---|
formData | this page's saved answers |
formErrors | this page's errors, by field, after a POST that failed |
casa.csrfToken | the CSRF token, for a form you write yourself |
casa.waypoint, casa.mountUrl | the current waypoint, and where the app is mounted |
casa.editMode, casa.editOrigin | whether 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, assetPath | what 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:
| Macro | CASA adds |
|---|---|
casaGovukInput, casaGovukTextarea, casaGovukCharacterCount, casaGovukSelect | an id of f- and the name, and the field's error from casaErrors |
casaGovukRadios, casaGovukCheckboxes | the same; checkboxes are named with [], so the answer is always a list, and are ticked from casaValue |
casaGovukDateInput | fields named name[dd], name[mm] and name[yyyy], filled from casaValue |
casaPostalAddressObject | the separate address fields that postalAddressObject checks |
casaJourneyForm | a 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:
{% 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.
{{ 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
}) }}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():
{% 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") }] }
}
] }) }}As text
- Browser to CASA: GET /check-answers
- CASA to Browser: Change links to /licence?edit=true&editorigin=/check-answers
- Browser to CASA: GET the Change link
- CASA: the page, with a Cancel link back to the origin
- Browser to CASA: POST the new answer, still in edit mode
- CASA: validate and save
- CASA to Browser: 302 to /check-answers, keeping the edit flags
- 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:
// 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:
// 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.storethat 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:
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 9 | CASA 10 |
|---|---|
| Node 18 to 24 | Node 22 or later |
| Express 4 | Express 5, so your own routes change: see chapter 5 |
govuk-frontend 5, and a govukRebrand option | govuk-frontend 6; the option is gone, and the new header is always used |
layout blocks beforeContent, skipLink and header | containerStart, govukSkipLink and govukHeader |
translation files ending .json or .yaml | .yml as well |
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.
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 },
],
},
];- One object for each waypoint, passed to
configure(). - The template, looked for in your views folders first, then CASA's.
- A field, named as the form field is, with its validators, which run in order.
- A plain string is shown as it is. A key such
as
renewal:licence.errors.requiredwould be translated. - Every error the validators return is kept. The field shows the first.
- Only these values pass, so an altered form cannot save anything else.
- A regular expression, here ignoring case, so
fs-123456passes. - A waypoint with no fields: there is nothing to
validate, so its POST always reaches
preredirect. - A page hook, named without the
journey.scope. It builds the rows before the page renders. - 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:
{{ 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:
// 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:
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:
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;
}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.
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.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