Building a blog with Fastify, SQLite, and zero frameworks

6 min read
TypeScript code editor showing a build app configuration file with Fastify setup and environment variables

When I rebuilt this blog from scratch, I had a choice. Reach for a popular framework and a managed platform, or build something I actually understood end to end.

I went with Fastify, SQLite, server-rendered Nunjucks templates, and vanilla CSS. No frontend framework. No ORM. It has been running in production for a while now, and I thought it was worth writing up why.

The stack

No webpack, no React, no GraphQL. The whole thing runs on one cheap server.

Why Fastify over Express

Express served me well for years, but Fastify is what Express should have become. It’s faster, has a proper plugin system, and TypeScript support feels native rather than bolted on.

The plugin system is the selling point for me. The database connection, session handling, CSRF(cross-site request forgery) protection, static files, flash messages: each one is a self-contained Fastify plugin. They compose cleanly and have proper lifecycle hooks.

What I really appreciate is the encapsulation model. Plugins can’t leak into each other’s scope unless you explicitly allow it. After years of Express middleware chains where ordering was everything and debugging meant tracing through a global stack, this was a welcome change.

Why SQLite

This was probably the most unconventional choice and the one I’m happiest with.

A personal blog doesn’t need Postgres. It doesn’t need connection pooling, replication, or a separate database server. It needs a fast, reliable store for some posts, image metadata, and a search index. SQLite handles all of that from a single file sitting next to the application.

I use better-sqlite3 rather than the async SQLite bindings. It’s synchronous, which sounds like a red flag, but it’s actually a great fit. Queries against a local file complete in microseconds. There’s no network round trip. You call .get() or .all() and the data is there immediately.

The entire database setup is four pragmas:

pragma journal_mode = WAL;
pragma synchronous = NORMAL;
pragma foreign_keys = ON;
pragma busy_timeout = 5000;

WAL(Write-Ahead Logging) is the important one. It lets readers and writers work concurrently, so the site stays responsive even while I’m editing a post in the admin panel. That’s all the database configuration there is. No tuning guides, no connection strings to rotate.

Full-text search without a separate service

One of SQLite’s best features for a blog is FTS5, its built-in full-text search engine. The search on this site isn’t powered by Algolia or Elasticsearch. It’s a virtual table:

CREATE VIRTUAL TABLE posts_fts
USING fts5(
  title,
  description,
  body,
  content='posts',
  content_rowid='id'
);

Queries use MATCH with BM25 ranking, which is a relevance scoring algorithm that weighs how important a search term is within a document:

SELECT p.*, bm25(posts_fts, 5.0, 1.0, 0.5) AS rank
FROM posts_fts
JOIN posts p ON p.id = posts_fts.rowid
WHERE posts_fts MATCH ?
  AND p.published = 1
ORDER BY rank
LIMIT 20

The weighting (5.0, 1.0, 0.5) prioritises title matches over description, and description over body. No API keys, no indexing pipeline. The search index lives in the same file as everything else and stays in sync on its own.

Why no ORM

I write SQL directly with prepared statements. A typical query:

const post = req.server.db
  .prepare<[string], Post>(
    `SELECT p.*, i.url as thumbnail_url
     FROM posts p
     LEFT JOIN images i ON p.thumbnail_image_id = i.id
     WHERE p.slug = ?`
  )
  .get(slug);

No model definitions, no query builder, no migration DSL. The SQL is right there in the controller. When something breaks or needs optimising, I’m looking at the actual query rather than trying to reverse-engineer what an abstraction layer generated.

better-sqlite3 returns plain JavaScript objects. A row comes back as { id: 1, title: "...", published: 1 }. No hydration step, no lazy loading. For a blog with a handful of tables, the schema fits in my head and SQL is plenty expressive on its own.

Why server-rendered templates

This site ships zero framework JavaScript. Every page is rendered on the server with Nunjucks and sent as HTML. CSS custom properties handle theming. The bits of client-side interactivity like the mobile menu and theme switcher are vanilla JS. Uploaded images are resized and converted to WebP using sharp, which keeps page weight down without me having to think about it.

For a blog, this is genuinely freeing. There’s no hydration, no client-side routing, no bundle to worry about. Time to first byte is basically the time it takes to run a query and render a template, which against SQLite is near-instant.

I picked Nunjucks specifically because I spent five years working in public sector, where it’s the template engine behind the GOV.UK frontend framework. I already knew it well, and it does everything I need: template inheritance, includes, macros, and filters. Fastify has a view plugin that wires it in, and templates auto-reload during development.

A blog is a list of posts and a way to read them. Server-rendered HTML is the right tool for that job.

Deployment

The whole thing runs in Docker Compose: the Node app, nginx handling SSL termination, and certbot managing Let’s Encrypt certificates. Three containers on one VPS(virtual private server).

A push to main triggers a GitHub Actions workflow that SSHs into the server, pulls the latest code, rebuilds the container, and runs database migrations. About a minute from commit to live.

There’s no CDN in front of it. Nginx serves static assets with long cache headers and the pages themselves render fast enough that I haven’t needed application-level caching.

What I’d do differently

If I were starting over, a few small things:

The core choices though, Fastify, SQLite, and Nunjucks, I wouldn’t change.

Boring is underrated

I spent more time choosing this stack than I’d like to admit. I read comparison posts, watched conference talks, started a prototype I quietly abandoned. All to end up with tools that have been around for years and don’t trend on Twitter.

But that’s kind of the point. I can open this project after months away and understand it immediately. There’s no framework version migration looming over me. When something breaks, the problem is in my code, not three layers deep in an abstraction I forgot I depended on.

It won’t win any “look at my stack” conversations. But it works, I enjoy maintaining it, and it gets out of my way so I can actually write. For a personal blog, I’m not sure what else you’d want.