More Backend Than a Blog Needs
A blog needs a folder of HTML and a way to serve it. This one runs three services, a shared data-stack of three engines, a work queue, and more Server-Sent-Event streams than a personal site has any business having. I want to be upfront about that, because everything else follows from it: pablofr.com is deliberately more system than the reading experience needs, because the site is also the workshop where I build the things it shows off.
The nice surprise is that most of it earns its keep anyway. Full-text search over the writing, versioned dataset downloads that get converted on the fly, a private dashboard that updates live. None of those are things a static folder does well. So the question I'm answering here isn't "what's the least a blog needs" (nothing like this), it's "what shape does it take once you decide the site is a real app and you want to own the whole thing."
The shape#
Everything sits behind one reverse proxy. Your browser only ever talks to two things, the site and the API, and those two lean on a small shared data-stack. One background worker hangs off the side for the slow, streaming work.
There are three runtime services, and they're genuinely separate processes, not one app wearing three hats. There's web (Next.js on Bun), core-api (a Hono API on Bun), and worker (a small Bun loop). They share nothing but the data-stack and a monorepo, wired with Bun workspaces and driven by Turborepo, so a single bun run dev boots all three at once. In production each one is its own container.
The web is mostly a reader#
The front of the house is Next.js 16 on the App Router, almost entirely React Server Components. The public side is small on purpose (home, projects, downloads, and the blog), and the blog is the fun part, because the page you're reading isn't a database row poured into a template. It's an MDX file compiled on the server.
When you open an article, the route reads the .mdx off disk, pulls the frontmatter with gray-matter, and renders the body with next-mdx-remote through a remark/rehype pipeline: GitHub-flavoured markdown, a Shiki-based code highlighter, and a handful of custom plugins. Every diagram, callout and code card in this very post is a React component the MDX is allowed to call.
Behind the login there's the other half of the web app, a dashboard I use to run the site: create short links, send newsletter broadcasts, read the download and short-link analytics, edit and reorder links, and publish versioned downloads. It's drag-and-drop and live-updating, and it's out of scope here on purpose. It exists so I never have to open a database console to run the place.
The API is where the work is#
core-api is a Hono app on Bun. Hono is the router and the middleware, Bun is the runtime and the server. The whole thing is one fetch handler exported to Bun, and two settings quietly give away what it does:
const app = new Hono();
// idleTimeout for long-lived SSE; a big body cap for dataset uploads.
export default { port, fetch: app.fetch, idleTimeout: 120, maxRequestBodySize: 300 * 1024 * 1024 };Inside, it's boringly layered, and the boredom is the whole point. Every request walks the same path, so there's only ever one place to look.
A route mounts a controller. The controller is a small Hono sub-app that validates input with zod and calls into a core/ module. The core module holds the actual logic and leans on a Postgres repository built with Drizzle. There are about a dozen of these route groups: posts, search, downloads, newsletter, short-links, projects, media, auth, and the generative-art endpoint that renders the strips in my other posts. Data lives in Postgres (the pgvector image, with UUIDv7 primary keys), reached through Drizzle for reads and writes.
Search that's half switched off#
Search is the clearest example of building the whole thing and then being honest about what's actually running.
The design is hybrid. A query fans out two ways at once: Meilisearch for fast keyword hits over post metadata, and pgvector for a semantic pass, cosine distance over embeddings stored right next to the rows in Postgres. The two result sets get merged with a recency boost, and the vector call is wrapped in a 350 ms timeout so a slow semantic pass can never hold up the keyword answer.
Here's the honest bit. The embeddings come from OpenAI's embedding models (text-embedding-3-small, 1536 dimensions), and that call sits behind a flag I keep off. The reason is money, plainly: I didn't want every post edit to hit a paid API, and I didn't want the site to depend on a billed third party just to save a row. The vector columns and their HNSW indexes exist in the schema, they're just empty. So today, in practice, search is Meilisearch keyword-only, and the semantic half is a switch I can flip the day I decide the embeddings are worth paying for. The machinery is real, the bill is opt-in.
One real queue, and the worker on the end of it#
Most of what looks like "events" in here is Redis Pub/Sub, fire-and-forget notifications that fan out to live dashboards. There's exactly one durable work queue, and it exists for a specific job: turning an uploaded dataset into other formats without blocking the request that asked for it.
It's a Redis Stream. When you ask for a conversion, core-api doesn't do the work, it appends a job and returns straight away:
await redis.xadd("pablofrcom:downloads:conversions", "*", "payload", JSON.stringify(job));The worker is a single Bun process blocking on that stream as part of a consumer group. It pulls one job at a time, streams the file through the conversion (JSON into Parquet, CSV or Excel), pushes the result to object storage, marks the format ready in Postgres, and acknowledges the job. Progress doesn't come back through the queue, it goes out over Pub/Sub, which core-api relays to your browser as Server-Sent Events, so the download page fills in live.
The split matters. The stream gives the job durability and at-least-once delivery, so if the worker dies mid-convert the job is still there when it comes back. Pub/Sub gives the progress bar immediacy without persistence, which is exactly the right trade for something nobody needs replayed.
Redis is not the cache#
It's tempting to file Redis under "cache" and move on, but on this box it's closer to the nervous system. Here's what it's doing, in rough order of how much I'd miss it:
- The message bus. Every live view in the dashboard (new short-link clicks, download progress, session revocations) is a Redis Pub/Sub channel fanned out to browsers over SSE.
- The work queue. The one stream above.
- The rate limiter. A sliding-window counter (a small
MULTIofGET/INCR/EXPIRE) keyed by route and a client fingerprint. - Sessions and idempotency. Sessions verify Redis-first with a Postgres fallback, and write endpoints stash their response under an idempotency key so a retried request replays the original result instead of doing the work twice.
- And, yes, a cache, for short-link lookups, search results, and the "now playing" I pull from Last.fm.
None of that is exotic. The point is that a single, boring Redis instance covers five different jobs, and not reaching for a sixth engine to do any of them is a feature, not a compromise.
How this very page got here#
My favourite loop in the whole system is the one that published this post. There's no CMS step for writing, I just add an .mdx file, and a watcher in core-api does the rest.
The watcher debounces, parses the frontmatter, upserts a row into Postgres (as PUBLISHED on first sight), and pushes the searchable fields to Meilisearch. The article body never goes to the search index, only the metadata does, which is why search finds posts but the reading always comes from the MDX. In production the same sync runs on every boot, so the posts baked into the image and the database can't drift apart.
Where it runs, briefly#
All of it lives on a single VPS. Traefik terminates TLS and routes pablofr.com to web and api.pablofr.com to core-api, with certificates from Let's Encrypt. Deploys are unglamorous and I like them that way: no CI, just rsync the source to the box and docker compose build && up over SSH, with Watchtower redeploying a service when its image tag moves. The box itself (the shared engines, the backups, the tenant model) is its own story.
The honest limits#
Saying the bet out loud means owning the costs of it too.
- The contract is duplicated by hand.
core-apiand the worker agree on the job's shape and the stream's name by both hard-coding them. There's no shared package keeping them in sync, just two files and some discipline. - Half the search is dark. The semantic path is built and switched off. Until the embeddings are on, "hybrid search" is an accurate description of the code and a generous one of the behaviour.
- One box means one blast radius. The engines are shared, so a runaway query or a memory spike is everyone's problem at once. That's the right trade for a handful of low-traffic services and the wrong one the day any single part gets popular.
- The deploy is a person, not a pipeline. rsync-and-SSH is fine for one maintainer and honest about it, but it's not what I'd hand to a team.
None of these are accidents. They're just the shape of choosing to own the whole thing on one machine, as one person, and preferring a system I can hold in my head over one that's someone else's to run. A blog needs none of it. But the site was never only a blog, it's where the rest of the work lives, and that turns out to be exactly the amount of backend that's worth having.
Related articles
whatsapp-web.js runs a whole headless Chromium to act like a phone. The other door skips the browser and speaks WhatsApp's native protocol over a plain WebSocket, then hands you a raw socket and a firehose of events. whatsweb is the thin layer I built to make that socket feel like a bot.
The same bot, a user's message answered by code I wrote, costs a handful of lines on Telegram, a gateway and a bag of intents on Discord, and a whole headless browser pretending to be a phone on WhatsApp. Four libraries in, the pattern was clear, and the hard part was never the bot.