LLM-focused reference for adding form validation to pages in this prototype.
Most pages don't need validation. Add it only on:
- Crucial pages where missing data would break downstream logic
- Pages where we specifically want to test validation UX
- Route checks submitted data, builds error objects, flashes them, redirects back
- Layout renders the error summary automatically (no page-level code needed)
- Template uses
| populateErrorsfilter on form components to show inline errors
Each error object has three properties:
{
text: "Select why this appointment has been stopped", // Error message shown to user
name: "appointment[appointmentStopped][stoppedReason]", // Matches component's `name` param
href: "#stoppedReason" // Links to the field's id in error summary
}namemust exactly match the form component'snameattribute — this is howpopulateErrorsmatches errors to componentshrefshould point to the element id (with#prefix) for the error summary link to scroll to
router.post('/clinics/:clinicId/appointments/:appointmentId/my-page-answer', (req, res) => {
const { clinicId, appointmentId } = req.params
const data = req.session.data
const someField = data.appointment.someField
if (!someField) {
req.flash('error', {
text: 'Select an option',
name: 'appointment[someField]',
href: '#someField'
})
res.redirect(`/clinics/${clinicId}/appointments/${appointmentId}/my-page`)
return
}
// Multiple errors
const errors = []
if (!data.appointment.fieldA) {
errors.push({
text: 'Enter field A',
name: 'appointment[fieldA]',
href: '#fieldA'
})
}
if (!data.appointment.fieldB) {
errors.push({
text: 'Select field B',
name: 'appointment[fieldB]',
href: '#fieldB'
})
}
if (errors.length) {
errors.forEach((err) => req.flash('error', err))
res.redirect(`/clinics/${clinicId}/appointments/${appointmentId}/my-page`)
return
}
// Success — continue
res.redirect(`/clinics/${clinicId}/appointments/${appointmentId}/next-page`)
})Pipe the component config through | populateErrors:
{{ radios({
small: true,
name: "appointment[someField]",
value: appointment.someField,
fieldset: {
legend: {
text: "Select an option",
classes: "nhsuk-fieldset__legend--m"
}
},
items: [
{ value: "Yes", text: "Yes" },
{ value: "No", text: "No" }
]
} | populateErrors) }}The filter looks up flash.error for an error with a matching name and adds errorMessage to the component config automatically.
dateInput uses namePrefix not name, but populateErrors handles both — just pipe it as normal:
{{ dateInput({
id: "dateStarted",
namePrefix: "appointment[symptomTemp][dateStarted]",
hint: { text: "For example, 3 2025" },
items: [
{ name: "month", classes: "nhsuk-input--width-2", value: appointment.symptomTemp.dateStarted.month },
{ name: "year", classes: "nhsuk-input--width-4", value: appointment.symptomTemp.dateStarted.year }
]
} | populateErrors) }}When radios/checkboxes are split across columns and can't use populateErrors directly on the component (because the fieldset is separate), use getFlashError manually:
{% set _locationError = 'appointment[symptomTemp][location]' | getFlashError %}
<div class="nhsuk-form-group{{ ' nhsuk-form-group--error' if _locationError else '' }}">
{% call fieldset({
legend: { text: "Where is it located?", classes: "nhsuk-fieldset__legend--m" },
describedBy: "location-error" if _locationError
}) %}
{% if _locationError %}
{{ errorMessage({
id: "location-error",
text: _locationError.text
}) }}
{% endif %}
{# Split radios across columns here #}
{% endcall %}
</div>The error summary is rendered automatically by the layout. No page-level code is needed — _includes/flash-messages.njk renders it when flash.error exists:
{% if flash.error %}
{{ errorSummary({
titleText: "There is a problem",
errorList: flash.error
}) }}
{% endif %}This works because the errorList array uses the same { text, href } shape that NHS errorSummary expects.
app/locals.jsmakesres.locals.flasha getter that reads the flash the first time something asks for it, usually when a page renders- Templates access it as
flash.error,flash.successetc. - Flash messages are consumed on read, so they appear once. A request that only redirects doesn't read them, so a message survives a chain of redirects to the page that renders
When a form can be submitted inside a modal (AJAX), the route must handle both paths:
const isModal = req.headers['x-requested-with'] === 'XMLHttpRequest'
if (errors.length) {
if (isModal) {
// Re-render the form fragment with errors (no redirect)
return res.status(422).render('my-page', {
flash: { error: errors }
})
}
// Standard redirect for non-JS
errors.forEach((err) => req.flash('error', err))
return res.redirect('/my-page')
}- Route posts to a separate
-answerURL (not same page) - Each error has
text,name, andhref -
namematches the component'snameattribute exactly -
hrefmatches the field'sidattribute (with#prefix) - Components use
| populateErrors(orgetFlashErrorfor dateInput/custom layouts) - On error, redirect back to the form page (errors show automatically)
- On success, redirect forward to next page