Build a blog API with Fastify, JSON Schema, and SQLite
Create a runnable Fastify blog API with SQLite, static files, schema validation, CRUD routes, lifecycle hooks, and structured error handling.
Fastify combines HTTP routing, schema-based validation, structured logging, and a plugin system in a Node.js framework. You create an instance, register routes and plugins, and start listening after the application is configured.
This tutorial builds a small blog API with SQLite persistence. It includes a home route, static files, create/read/update/delete endpoints, request hooks, and error handling. The result is a local learning application; authentication and production deployment need their own design.
Understand the pieces before adding routes
Fastify uses JSON Schema to validate request data and can use response schemas to serialize output. Its plugin system provides encapsulation so that a module can have its own decorators, hooks, and routes. Pino supplies structured logging when you enable logger: true; logging is not enabled by default.
These features reduce the amount of application plumbing you need, but they do not automatically generate API documentation or define your security policy. Documentation generation requires an appropriate plugin and configuration. Schema compilation also has a trust boundary: use application-controlled schemas, not arbitrary schemas supplied by users. See Fastify's validation and serialization reference.
Create the project
Use Node.js 24 for this example. The dependencies below select Fastify 5 and a compatible major version of its static-file plugin. Keep the generated lockfile so the same dependency versions can be installed again.
mkdir fastify-blog
cd fastify-blog
npm init -y
npm install fastify@5 @fastify/static@8 better-sqlite3@12
mkdir public
better-sqlite3 includes a native component. Installation may need a supported prebuilt binary or the build tools for your operating system. Create public/hello.txt with a short message; the application will expose it at /assets/hello.txt.
The example uses CommonJS consistently. Save the following application as app.cjs. Keeping imports, hooks, plugins, routes, and startup together avoids a common tutorial problem: trying to add routes after the server has already started.
const Fastify = require('fastify');
const fastifyStatic = require('@fastify/static');
const Database = require('better-sqlite3');
const path = require('node:path');
function buildApp({ databasePath = 'blog.db', logger = true } = {}) {
const app = Fastify({ logger });
const db = new Database(databasePath);
db.exec(`CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
app.addHook('onClose', async () => {
db.close();
});
// Register hooks before the routes they should affect.
app.addHook('onRequest', async (request) => {
request.log.info({ method: request.method }, 'Handling blog request');
});
app.addHook('onSend', async (request, reply, payload) => {
reply.header('X-Powered-By', 'Fastify');
return payload;
});
app.setErrorHandler((error, request, reply) => {
const status = Number.isInteger(error.statusCode)
&& error.statusCode >= 400 && error.statusCode < 600
? error.statusCode : 500;
if (status >= 500) {
request.log.error({ err: error }, 'Request failed');
} else {
request.log.warn({ err: error }, 'Request rejected');
}
reply.code(status).send({
error: status >= 500 ? 'Internal server error' : error.message,
});
});
app.register(fastifyStatic, {
root: path.join(__dirname, 'public'),
prefix: '/assets/',
});
const bodySchema = {
type: 'object',
additionalProperties: false,
required: ['title', 'content'],
properties: {
title: { type: 'string', minLength: 1, maxLength: 200 },
content: { type: 'string', minLength: 1 },
},
};
const idSchema = {
type: 'object',
required: ['id'],
properties: { id: { type: 'integer', minimum: 1 } },
};
app.get('/', async () => ({ message: 'Welcome to the blog' }));
app.post('/posts', { schema: { body: bodySchema } },
async (request, reply) => {
const { title, content } = request.body;
const result = db.prepare(
'INSERT INTO posts (title, content) VALUES (?, ?)'
).run(title, content);
return reply.code(201).send({
id: Number(result.lastInsertRowid), title, content,
});
});
app.get('/posts', async () => db.prepare(
'SELECT * FROM posts ORDER BY created_at DESC, id DESC'
).all());
app.get('/posts/:id', { schema: { params: idSchema } },
async (request, reply) => {
const post = db.prepare('SELECT * FROM posts WHERE id = ?')
.get(request.params.id);
if (!post) return reply.code(404).send({ error: 'Post not found' });
return post;
});
app.put('/posts/:id', {
schema: { params: idSchema, body: bodySchema },
}, async (request, reply) => {
const { title, content } = request.body;
const { changes } = db.prepare(
'UPDATE posts SET title = ?, content = ? WHERE id = ?'
).run(title, content, request.params.id);
if (!changes) return reply.code(404).send({ error: 'Post not found' });
return { id: request.params.id, title, content };
});
app.delete('/posts/:id', { schema: { params: idSchema } },
async (request, reply) => {
const { changes } = db.prepare('DELETE FROM posts WHERE id = ?')
.run(request.params.id);
if (!changes) return reply.code(404).send({ error: 'Post not found' });
return reply.code(204).send();
});
return app;
}
if (require.main === module) {
const app = buildApp();
app.listen({ port: 3000, host: '127.0.0.1' }).catch(async (error) => {
app.log.error({ err: error }, 'Failed to start');
await app.close();
process.exitCode = 1;
});
}
module.exports = { buildApp };
Start the application:
node app.cjs
Fastify waits for registered plugins during startup. Binding to 127.0.0.1 keeps this example local; a container or deployed service needs an explicit listening address and network policy. The getting-started guide explains registration and startup.
Check the API from creation through deletion
Create a post with a JSON body:
curl -i http://127.0.0.1:3000/posts \
-H 'Content-Type: application/json' \
--data '{"title":"First post","content":"A small Fastify blog."}'
A successful response uses status 201 and includes the new ID. Use that returned ID in subsequent requests; the examples below assume it is 1.
curl http://127.0.0.1:3000/posts
curl http://127.0.0.1:3000/posts/1
curl http://127.0.0.1:3000/assets/hello.txt
curl -i -X PUT http://127.0.0.1:3000/posts/1 \
-H 'Content-Type: application/json' \
--data '{"title":"Updated post","content":"The revised content."}'
curl -i -X DELETE http://127.0.0.1:3000/posts/1
Deleting an existing row returns 204 with no response body. Fetching, updating, or deleting a nonexistent row returns 404. All SQL values are passed through bound parameters rather than concatenated into SQL strings.
The list endpoint deliberately returns every row to keep the example small. Add pagination before allowing the table to grow without bound. SQLite operations through better-sqlite3 are synchronous: marking the route async does not move database work off Node.js's event loop. Evaluate query duration and write concurrency before using this design for a busy service.
Validate both bodies and route parameters
The body schema requires a nonempty title and content, with a 200-character maximum for the title. Fastify validates the JSON request before entering the handler. For example, this request should return 400:
curl -i http://127.0.0.1:3000/posts \
-H 'Content-Type: application/json' \
--data '{"title":"","content":"This title is invalid."}'
The parameter schema requires a positive integer ID. With Fastify's default coercion behavior, a numeric URL parameter can become a number for the handler; /posts/not-a-number fails validation. Validation is not authorization: a well-formed request can still be from a user who is not allowed to edit that post.
Also inspect the validator's options before relying on how unknown properties are treated. The example sets additionalProperties: false, but Fastify's default Ajv configuration can remove additional properties rather than rejecting the request. Return an explicit set of fields rather than reflecting the entire request body into a response.
Use hooks at the appropriate lifecycle stage
The sample uses onRequest for a request-scoped log and onSend to demonstrate a response header. The identifying X-Powered-By header is optional; omit it if your deployment policy avoids exposing framework names. Fastify already emits request logs when logging is enabled, so the extra hook should capture useful application context rather than duplicate every built-in message.
A successful JSON request commonly passes through onRequest, preParsing, body parsing, preValidation, validation, preHandler, the handler, optional preSerialization, onSend, and onResponse. Errors can branch into a different path. A parsed body is not available in onRequest; place body-dependent work later. See the hook reference.
Keep the async style consistent. An async hook should complete by returning or throwing; do not also invoke a callback. Register hooks before the routes they are intended to cover, and remember that plugin encapsulation limits their scope.
Handle failures without exposing server details
Rejected promises and thrown errors in async route handlers enter Fastify's error handling. The sample preserves valid HTTP error statuses, logs server failures at error level, and replaces 5xx response details with a generic message. Validation failures remain 400; not every caught error becomes 500.
For deployment, consider @fastify/helmet for security headers, @fastify/cors for an explicit cross-origin policy, and @fastify/rate-limit for request limits. Select versions compatible with Fastify 5 and configure them for your traffic and proxy setup. Installing a plugin does not establish a safe CORS allowlist, a shared rate-limit store, authentication, or per-post authorization.
Trace a real business request in Guance
Once local behavior is correct, choose a route that includes a database query or external request. Follow the Node.js APM integration guide for tracer initialization order and the supported framework versions. Confirm the incoming request and any supported downstream spans are connected before adding dashboards.
Collect the structured logs, then inject the correlation fields using the documented log and trace association path. Use the logging integration to verify parsing and timestamps. A successful log upload does not establish that a trace ID was propagated correctly.
Group operational measurements by route templates such as /posts/:id or another stable operation name. Avoid unbounded dimensions such as user IDs, full dynamic URLs, or raw GraphQL documents.
Common questions
What happens when an async handler throws?
Fastify routes the error to its error handler. An unclassified failure normally becomes 500; validation and other errors can carry a different status. This behavior should be tested with your custom handler, especially when you replace error response bodies.
What does plugin encapsulation mean?
A plugin registered with register creates a scope. Its decorators and hooks are available in that scope and its descendants, without automatically leaking into the parent or sibling plugins. fastify-plugin can change that behavior when a plugin intentionally exposes shared capabilities. Use that escape hatch deliberately rather than making every module global.
Is Fastify a reasonable first framework?
Yes, particularly if you want to learn schemas, request lifecycles, and structured logs together. The concepts still need explicit configuration: routes without a schema are not automatically validated, and logging must be enabled. Start with the working API above, then add one production concern at a time.
Further reading: Bun for Node.js developers (Chinese) and TypeScript with SWC (Chinese).
Contact us
Join the community
to join the community
Try Guance
Start online and pay only for what you use.
Get startedChoose a Guance plan