I built a CMS in Convex. It worked. You could create posts, edit them in real-time, and they'd show up on the website instantly. Reactive queries, live preview, the works. It was genuinely cool engineering.
It was also completely wrong for the job.
The Problem with a Real-Time CMS for Static Content
Here's the thing about blog posts: they don't change in real-time. Nobody is sitting there refreshing your privacy policy hoping for a plot twist. Legal documents update maybe twice a year. Blog posts get published and then sit there, quietly accumulating dust and backlinks.
But we had them in Convex, which meant:
- Every page load was a database query. The privacy policy — a document that changes approximately never — was hitting our backend on every single visit. That's compute time, bandwidth, and latency we were paying for no reason.
- No version control. Want to see what the Terms of Service looked like three months ago? Good luck grepping through Convex's dashboard. Meanwhile,
git log --follow content/legal/terms.mdxgives you every revision with full context and blame. - No PR review for content. Legal copy changes were mutations in a dashboard. No diff, no approval flow, no "hey, did you actually read what we're publishing?" Just vibes and a publish button.
- Content was trapped. If we ever wanted to switch backends (unlikely with Convex, but still), our blog posts were locked in a proprietary database. MDX files are just text. They'll outlive every framework we'll ever use.
The Migration
PR #1 was deceptively simple: move everything to MDX files and delete the CMS code.
The new structure:
apps/web/content/
blog/
welcome-to-lumo.mdx
building-lumo-pro-usage-based-tiers.mdx
...
legal/
privacy-policy.mdx
terms-and-conditions.mdx
Each MDX file has frontmatter for metadata:
---
title: "Why I Built Lumo"
description: "The origin story nobody asked for"
author: "Lumo Team"
publishedAt: "2026-02-07"
tags:
- product
- insights
draft: false
---The rendering pipeline uses next-mdx-remote to compile MDX at build time. Static generation means the content is pre-rendered HTML — zero database queries, instant load, perfect SEO.
The Monorepo Setup
This was also when we set up the Lumo monorepo properly. Bun + Turborepo with three workspaces:
apps/
web/ # Next.js marketing site + blog
mobile/ # React Native app
admin/ # Admin dashboard (added later)
packages/
backend/ # Convex — shared by all apps
Unified TypeScript configuration across everything. One bun install at the root, one turbo build to build the world. The kind of developer experience that makes you wonder why you ever tolerated separate repos with diverging configs.
What We Kept in Convex
To be clear — Convex is still our entire backend for everything that actually needs a database: users, transactions, budgets, subscriptions, real-time sync. We just stopped abusing it as a CMS for static text files.
The rule is simple now: if it changes when a user interacts with the app, it lives in Convex. If it changes when a developer writes prose, it lives in MDX.
The Boring Lesson
The boring lesson is that the best tool for the job isn't always the most technically interesting one. Convex is incredible for real-time data. It's overkill for blog posts. MDX files in a git repo are boring technology, and boring technology is what you want for content that needs to be reliable, reviewable, and permanent.
Sometimes the best migration is the one that makes your system dumber.
Abdul Rafay Founder, Syntax Lab Technology