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:
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:
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:
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.
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 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:
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 pluginsimport 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).
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.
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:
app.get('/api/todos', async (req, reply) => {
const todos = req.server.services.db.prepare('SELECT * FROM todos').all();
return todos;
});
Why bother with the indirection? Two reasons that matter once a project grows past a toy example:
- Testing. In a test, you can build an app instance and decorate it with an
in-memory database or a fake event bus instead of the real one. The route handler code doesn't
change at all — it just asks the app instance for
services.db, and the test controls what that resolves to. - One source of truth. The database connection, the encryption key, the event bus — these should exist exactly once per running process. Decorating them onto the app instance at startup guarantees every handler shares the same instance, instead of each file that imports a "singleton" module accidentally creating its own.
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 #
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.