● David R. Longnecker
Chapter 9 · Backend

HTTP Servers & Routing

Chapter 1 showed you the shape of a request and a response. Now you'll build the thing that answers them. Node.js can speak raw HTTP on its own, but almost nobody builds real applications that way — a framework like Fastify buys you routing, parsing, and a plugin system, so you spend your time on application logic instead of plumbing.

What raw Node gives you (and doesn't) #

Node's built-in http module can start a server in a few lines:

the entire request handler, no framework
import http from 'node:http';

const server = http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/api/todos') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify([{ id: 1, title: 'Write chapter 9', done: false }]));
    return;
  }
  res.writeHead(404);
  res.end();
});

server.listen(3000);

This works, but notice everything it's not doing: it isn't parsing query strings or route parameters, it isn't validating the request body, it isn't handling multiple resources without a growing pile of if statements, and it has no concept of shared setup that every handler needs. As soon as you have more than three or four endpoints, you end up re-inventing a router by hand. A framework is that router, written once and tested by thousands of other projects.

A minimal route #

Every Fastify app starts the same way: create an instance, register routes and plugins on it, then tell it to start listening. That instance — conventionally called app — is what every example in this chapter builds on:

apps/server/index.js
import Fastify from 'fastify';

const app = Fastify({ logger: true });

// ...routes and plugins get registered on `app` here...

app.listen({ port: 3000 });

Fastify (and frameworks like it) let you declare an endpoint as a method, a path, and a handler function, called on that same app instance:

apps/server/routes/todos.js (db is a placeholder here — the "decorating services" section below shows where it actually comes from, and Chapter 10 covers the database itself)
app.get('/api/todos', async (req, reply) => {
  const todos = db.prepare('SELECT * FROM todos').all();
  return todos;
});

app.post('/api/todos', async (req, reply) => {
  const { title } = req.body;
  const result = db
    .prepare('INSERT INTO todos (title, done) VALUES (?, 0)')
    .run(title);
  reply.code(201);
  return { id: result.lastInsertRowid, title, done: false };
});

The framework parses the URL, matches it against every route you've registered, extracts path parameters (like the 42 in /api/todos/42), parses the JSON body into req.body for you, and turns whatever you return back into a JSON response with the right Content-Type header. You write the part that's specific to your app; the framework writes the part that's the same in every app.

Key idea

A handler function is just a callback the framework calls when a matching request arrives. It receives a request object and a reply object, and it can be async because most handlers need to await something — usually a database call.

req.body is untrusted, and untyped by default

req.body above is whatever the client sent — by default Fastify treats it as any, with no shape guarantee and no validation. Passing it straight into a SQL prepared statement is safe from SQL injection (Chapter 10 covers why), but nothing here stops a request with a missing or wrong-typed title from reaching the database. Fastify's native fix is a JSON Schema attached to the route:

app.post('/api/todos', {
  schema: {
    body: {
      type: 'object',
      required: ['title'],
      properties: { title: { type: 'string', minLength: 1 } },
    },
  },
}, async (req, reply) => {
  const { title } = req.body; // now guaranteed to be a non-empty string
  // ...
});

Fastify validates the request against the schema before your handler ever runs, and rejects it with a 400 automatically if it doesn't match. This tutorial mostly omits schemas to keep examples short, but a real route accepting write traffic from the network should have one.

Organizing routes: one file per resource #

Once you have more than a couple of endpoints, dumping them all into one file gets unwieldy fast. The common convention is one file per resource — todos.js, notes.js, users.js — each exporting a function that registers its own routes on the app:

apps/server/routes/notes.js
export function registerNoteRoutes(app) {
  app.get('/api/notes', async (req, reply) => {
    return app.services.db.prepare('SELECT * FROM notes').all();
  });

  app.get('/api/notes/:id', async (req, reply) => {
    const note = app.services.db
      .prepare('SELECT * FROM notes WHERE id = ?')
      .get(req.params.id);
    if (!note) {
      reply.code(404);
      return { error: 'not found' };
    }
    return note;
  });
}
apps/server/index.js
import { registerNoteRoutes } from './routes/notes.js';
import { registerTodoRoutes } from './routes/todos.js';

registerNoteRoutes(app);
registerTodoRoutes(app);

Each file only knows about its own resource. Adding a "tags" feature later means adding routes/tags.js and one more registration call — nothing in the existing files has to change.

Plugins for cross-cutting concerns #

Some behavior doesn't belong to any one resource — things like allowing your frontend's origin to make requests (CORS), or serving the built frontend's static files. Frameworks handle this with a plugin system: a plugin is a self-contained module you register once, and it wires itself into every request/response cycle from then on.

registering cross-cutting plugins
import cors from '@fastify/cors';
import staticFiles from '@fastify/static';

app.register(cors, { origin: 'http://localhost:5173' });
app.register(staticFiles, { root: './public' });

Neither of those plugins knows or cares whether you're building a todo app or a notes app. That's the point — they solve a problem that's the same across every project, so someone else already solved it and published it as a package (see Chapter 4).

Don't hardcode the CORS origin

origin: 'http://localhost:5173' above is a dev-time convenience, not something to ship. In production, a hardcoded origin either breaks (wrong URL) or, worse, gets loosened to origin: '*' under time pressure, which allows any website to call your API from a logged-in user's browser. Read the allowed origin from configuration or an environment variable instead, so it's an explicit decision per environment rather than a value baked into source code.

Sharing services: the "decorate" pattern #

Every route handler in the examples above needs the database. You could import a database module directly into every route file, but that couples each file to one specific way of creating that connection, and makes it awkward to substitute a different database (or a fake one) in tests. The alternative most frameworks offer is decoration: attach a shared value to the app instance once, at startup, and let every handler reach it through that instance instead.

wiring shared services onto the app once
import Database from 'better-sqlite3';

const db = new Database('app.db');
const eventBus = createEventBus();

app.decorate('services', { db, eventBus });

Now any route handler can reach the database through app.services.db (or, inside a handler, often just req.server.services.db) instead of importing a module-level singleton:

a handler that reaches shared services through the app instance
app.get('/api/todos', async (req, reply) => {
  const todos = req.server.services.db.prepare('SELECT * FROM todos').all();
  return todos;
});
A request enters the framework, gets matched to a route, the handler reaches decorated services on the app instance, and a response goes back out Client Server process (app instance) Router matches path Route handler runs app.services db eventBus config (decorated once at startup) Reply serialized to JSON
Handlers don't import the database module directly — they reach it through services decorated onto the shared app instance.

Why bother with the indirection? Two reasons that matter once a project grows past a toy example:

Tip

This is a form of dependency injection: instead of a handler reaching out and constructing (or importing) what it needs, the thing it needs is handed to it from outside. The handler stays simple and swappable.

Putting it together #

Try it

Sketch a route file for a "notes" resource with GET /api/notes, GET /api/notes/:id, and DELETE /api/notes/:id. Write the registration function signature only — you don't need a real database yet, that's Chapter 10. Focus on which parts of each handler would come from req.params versus app.services.

You now have a server that can route requests to the right code and reach shared services without tangled imports. The next chapter fills in what app.services.db actually is: an embedded SQLite database, and the migration pattern that keeps its schema under control as the app evolves.