Skip to content

A backend of your own with Node

Since part 7, the case app gets its data from the course API in the folder api of the course repo. The course API runs on Node with the built-in module node:http and no framework. If your project needs to store data that no public API provides, you need such a backend yourself. This page shows a small API for the catalog that you can read and extend.

According to the project assignment, a backend of your own earns four bonus points. Otherwise only the frontend is graded, so plan the SPA first and the server second. A backend pays off when your app creates or changes data that other people should see, or when it combines data from several sources. For an app that only reads, a public API or a JSON file is often enough.

The example lives in the course repo under optional/backend/en. The function createHandler answers every request. It checks the method and the path, searches the names of the things with ?q= and takes a member's wish for a new thing with POST. It reads the body of a request piece by piece to the end and checks it before storing anything.

app.js
// A small JSON API for the catalog with node:http and without packages. GET /api/items lists the
// things, ?q= searches their names, GET /api/items/:id returns one thing and POST /api/wishes
// takes the wish of a member for a new thing.
const MAX_BODY = 10_000
function send(response, status, body) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify(body))
}
// The body arrives in pieces of bytes, and a character such as ü can be split between two of them.
// The API joins the pieces, gives up above MAX_BODY bytes and reads the whole as UTF-8 once.
async function readJson(request) {
const chunks = []
let size = 0
for await (const chunk of request) {
size += chunk.length
if (size > MAX_BODY) return undefined
chunks.push(chunk)
}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
} catch {
return undefined
}
}
export function createHandler(items) {
const wishes = []
return async (request, response) => {
const url = new URL(request.url, 'http://localhost')
if (request.method === 'GET' && url.pathname === '/api/items') {
const query = (url.searchParams.get('q') ?? '').toLowerCase()
return send(response, 200, items.filter((item) => item.name.toLowerCase().includes(query)))
}
const match = /^\/api\/items\/(\d+)$/.exec(url.pathname)
if (request.method === 'GET' && match !== null) {
const item = items.find((candidate) => candidate.id === Number(match[1]))
if (item === undefined) return send(response, 404, { error: 'This thing does not exist.' })
return send(response, 200, item)
}
if (request.method === 'POST' && url.pathname === '/api/wishes') {
const body = await readJson(request)
const name = typeof body?.name === 'string' ? body.name.trim() : ''
if (name === '' || name.length > 60) {
return send(response, 422, { error: 'The wish needs a name of at most 60 characters.' })
}
const wish = { id: wishes.length + 1, name }
wishes.push(wish)
return send(response, 201, wish)
}
send(response, 404, { error: 'The API does not know this address.' })
}
}

server.js starts the server on port 3001. npm run dev restarts it with node --watch whenever you change a file.

server.js
// Starts the API with npm start on http://localhost:3001, PORT chooses another port
import { createServer } from 'node:http'
import { createHandler } from './app.js'
import items from './data/items.json' with { type: 'json' }
const server = createServer(createHandler(items))
server.listen(Number(process.env.PORT ?? 3001), () => {
console.log(`The API runs on http://localhost:${server.address().port}/api/items`)
})

The tests start the API on a free port and query it with fetch, the same way an SPA does. Node ships node:test and fetch for this, so you need no extra packages.

app.test.js
let server
let base
// Every test run starts the API on a free port and stops it at the end
before(async () => {
server = createServer(createHandler(items))
await new Promise((resolve) => server.listen(0, resolve))
base = `http://localhost:${server.address().port}`
})
after(() => server.close())
// Sends a JSON body in two pieces, split in the middle of the first character that needs two bytes
function sendInTwoPieces(path, body) {
const bytes = Buffer.from(JSON.stringify(body))
const middle = bytes.findIndex((byte) => byte >= 0xc0) + 1
return new Promise((resolve, reject) => {
const headers = { 'Content-Type': 'application/json', 'Content-Length': bytes.length }
const outgoing = sendRequest(`${base}${path}`, { method: 'POST', headers }, async (response) => {

The wishes only live in memory and are gone after a restart. That is why the course API writes its data to files, and a project with a lot of data uses a database.

The easiest way for the SPA to talk to the backend is the Vite proxy, as in the chapter Data from an API. To the browser, app and API then share one origin, and no CORS is needed. If the API runs at another address, it has to allow the browser to hand its answers to the app with the header Access-Control-Allow-Origin. The course API shows how in api/src/app.js. Credentials for a database or another service belong in environment variables of the server and never in the code of the SPA, because anyone can read everything in the SPA's build in the browser.

For a bigger API, frameworks take care of routes, reading JSON and handling errors. Since version 5, Express passes rejected promises on to its error handling by itself. Fastify validates requests with JSON Schema, and Hono builds on the web standards Request and Response. Node reads secret values with node --env-file=.env from a file that stays out of the repository.

Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.