Routes and responses
Negotiate HTML and JSON
Keep representation logic explicit when one application serves pages and an API.
The notes app has two kinds of clients. A browser submits a form and expects to land on a page. A script posts JSON and expects JSON back. Same note, same rule for creating it, two very different responses.
My advice is to give them two routes. /notes speaks HTML. /api/notes speaks JSON. The code that creates a note is shared and knows nothing about either.
The shared operation
src/notes/service.js holds the rule, and it never touches req or res:
export function createNote(store, { title, body }) {
const note = { id: store.nextId(), title: title.trim(), body }
store.save(note)
return note
}
The HTML path redirects
A browser form does a POST. If we render the new note directly in the response, refreshing the page resubmits the form and creates a duplicate. So we redirect:
pagesRouter.post('/notes', express.urlencoded({ extended: false }), (req, res) => {
const note = createNote(store, req.body)
res.redirect(303, `/notes/${note.id}`)
})
303 See Other tells the browser to follow with a GET. The address bar shows /notes/42, and refresh is safe. Note the argument order: in Express 5 the status comes first. The old res.redirect(url, status) form is gone.
The JSON path returns the resource
An API client can’t follow a redirect in a useful way. It wants the created object and its address:
apiRouter.post('/notes', express.json(), (req, res) => {
const note = createNote(store, req.body)
res.status(201).location(`/api/notes/${note.id}`).json(note)
})
Send both requests and compare:
curl -i -X POST http://localhost:3000/notes -d 'title=Buy+milk&body='
curl -i -X POST http://localhost:3000/api/notes \
-H 'Content-Type: application/json' -d '{"title":"Buy milk","body":""}'
The first answers 303 with Location: /notes/42 and no useful body. The second answers 201, Content-Type: application/json, and the note. Two contracts, both written down in code.
What about res.format()?
Express can also negotiate on one route using the Accept header:
app.get('/notes/:id', (req, res) => {
const note = findNote(req.params.id)
res.format({
html() { res.send(renderNote(note)) },
json() { res.json(note) },
default() { res.status(406).send('Not Acceptable') },
})
})
This is fine for read endpoints where the data is the same and only the wrapping changes. I avoid it for writes, because the HTML path redirects and the JSON path doesn’t, and hiding that difference behind one route makes both harder to test.
Don’t sniff the user agent
The shortcut that goes wrong is if (req.headers['user-agent'].includes('Mozilla')). curl can send any user agent. A browser fetch() call sends the browser’s. The header describes the software, not what it wants back. Use the route or the Accept header, which exist for this purpose.
Try this on your project: write one supertest for each POST and assert the status, the Location header, and whether a body came back.
Lesson completed