A DB-backed CMS with a Rust axum JSON API, a Next.js frontend, and a custom markdown renderer that powers the `:::` directive blocks used across these case studies. Authoring happens entirely in a browser admin — Postgres is the single source of truth, no git/markdown ingest step.
Off-the-shelf CMSs hide exactly the parts worth learning. Building it surfaced real decisions about boundaries, rendering, and a browser-based authoring experience.
Architecture
backend (Rust)
owns: domain, API, rendering
axumsqlxhexagonal-leaning
A modular monolith: pure `domain`, `app` port traits, and `infra` adapters (Postgres repo, syntect renderer, local-disk storage). The web layer is a thin JSON API (`api/`) that maps `ApiError` to HTTP — no business logic in handlers.
frontend (Next.js)
owns: SSR, admin editor
React 19TipTapSCSS
Server-rendered reading experience plus a WYSIWYG admin editor that serializes back to markdown — so the render pipeline stays the single source of truth. Theme resolution runs identically on the server and in an inline pre-paint boot script, so there's no flash of the wrong theme.
No ORM, no compile-time `query!` macros coupling the build to a live database — correctness comes from integration tests on the repository. `site_settings` is a key/JSONB table for owner-wide config (themes, fonts, topic colors, brand/social identity) that reaches every visitor, read through an in-process moka cache in front of Postgres.
render pipeline
owns: markdown, directives, syntax highlighting
comraksyntectdirective AST parser
Markdown flows through three passes: code fences get syntect classed-span highlighting, `:::` directives parse into a proper AST (not a scan-and-rescan preprocessor), then comrak handles the rest. Rendering happens on save, not on read.
The directive renderer
The interesting part is the renderer. Markdown flows through three passes: code
fences are syntax-highlighted with syntect (classed spans, not baked-in colors —
code blocks follow the active theme), then ::: directives parse into an AST and
become themed HTML, then comrak handles the rest.
Posts are stored as markdown, not HTML. The WYSIWYG editor round-trips to markdown
so the backend renderer always produces the final HTML — no drift between what you
edit and what ships. The directive parser never fails outright either: malformed
input still renders best-effort, with problems surfaced as line-accurate diagnostics
the editor preview can show.
Design decisions
01
A Rust API, decoupled from the frontend
axum JSON API + independent Next.js frontend
"Two apps, one contract"
The backend is a pure JSON API — no HTML rendering, no framework coupling to the frontend. That boundary lets the frontend use the best tool for a rich editor UX (React + TipTap) without constraining the backend's ecosystem, and either side deploys independently as long as the API contract holds.
"Rust where correctness matters, React where iteration speed matters"
Domain logic, rendering, auth, and storage all live in Rust, where the type system and `cargo test` catch mistakes early. The admin editor and public site live in React/Next.js, where a mature component ecosystem (TipTap, React Query) moves faster than hand-rolling the same UI in Rust would.
"Hexagonal-leaning architecture"
Ports are defined at the real seams — `ContentRepository`, `Renderer`, `StorageBackend`, `AuthProvider`, `SiteSettingsRepository`, `Mailer` — each with a concrete Postgres/local-disk/SMTP adapter behind it, so a second implementation (S3 storage, a different mailer) is a new adapter, not a rewrite.
"sqlx runtime-checked queries, not query! macros"
Compile-time query macros couple every build to a live database and slow iteration. Correctness comes from integration tests on the repository instead, and migrations stay the single schema authority.
"Two API URLs, one for SSR, one for the browser"
The frontend calls the backend through `API_INTERNAL_URL` during server-side rendering (a direct container-to-container hop in production) and through the public URL from the browser — same API, no CORS dance for the common case, and Caddy only has to proxy one path publicly.
02
Stateless auth, no session store
JWT · argon2 · no tower-sessions
"JWT over server-side sessions"
Auth is a signed JWT validated per request — there's no session store to run, back up, or scale. A release build refuses to start without an explicit signing secret.
"argon2 for password hashing"
The single-owner admin's password is hashed with argon2, not a faster general-purpose hash — deliberately expensive to slow down offline brute-force if the hash ever leaked. The owner can rotate it from the admin settings UI without touching the database directly.
"Origin-scoped CORS in release"
A release build restricts CORS to the configured site origin — the API doesn't answer cross-origin requests from anywhere else.
03
Theme and identity: DB-backed, not localStorage
7 families · fonts · custom themes · site identity
"Every visitor sees the owner's actual choice"
Theme, fonts, brand text, logo/favicon, and social links all live in `site_settings`, not a browser's localStorage — the owner sets them once from the admin UI and every visitor sees the same thing, applied before first paint via a blocking inline boot script.
"Seven families, each with a mode"
Default, Nord, Gruvbox, Rosé Pine, Catppuccin, One, and Ayu, each with dark and light variants selected by the family's `mode` (light/dark/auto). Custom owner-defined palettes derive a full ~20-variable theme from four input colors and store alongside the built-ins.
"Topic colors are fixed hex, not theme-derived"
A topic's color is its identity — how a reader recognizes "this is a security post" across every list and badge — so it stays constant across themes rather than shifting with the active palette.
"Coding fonts ship self-hosted, subsetted per font"
Nerd Font icon glyphs are subsetted and self-hosted per coding font rather than pulled from a CDN — the public site loads only the two fonts actually in use, while the admin settings page loads the full catalog once for live preview across every option.
`directive::parse` walks the document once, recursively, into a tree of `Block { name, arg, body, children }`. The parser never fails outright — a malformed directive still renders best-effort with a line-accurate diagnostic instead of silently dropping content.
"Classed-span code highlighting, not inline RGB"
Syntect tokenizes each line into CSS classes, not literal color values baked into `style=` attributes — code blocks follow the active theme and light/dark mode like the rest of the page.
"Hand-authored SVG diagrams, themed with CSS vars"
The `:::diagram` directive passes raw SVG through unescaped — authors use `var(--acc)`, `var(--blue)`, and friends directly in `fill`/`stroke` attributes, so an architecture or ER diagram re-colors with the visitor's theme instead of staying a fixed palette baked in at authoring time.
05
Admin authoring: no git, no markdown ingest
browser editor · revisions · media library
"One browser editor, not a git workflow"
The entire site is authored from a browser admin — no local markdown files, no git push to publish. Postgres is the single source of truth; the WYSIWYG editor round-trips to markdown so the same renderer produces the final HTML for every save.
"Every save is a revision"
Each admin save snapshots the previous version into `document_revisions` before writing the new one — the editor can browse, diff, and restore any past revision without a separate versioning system.
"A real media library, not orphaned uploads"
Every upload is recorded in `media` — original filename, MIME type, byte size — even though the stored file itself sits under a random UUID name on disk. The library can always answer "what was this?" without touching the filesystem.
Engineering depth
rust
Classed-span syntax highlighting
Syntect tokenizes each line into CSS classes, not literal color values baked into inline `style=` attributes. Code blocks follow the visitor's active theme and light/dark mode like the rest of the page.
rust
Directive AST, parsed once
`directive::parse` walks the document once, recursively, into a tree of `Block { name, arg, body, children }`. The parser never fails outright; a malformed directive still renders best-effort with a line-accurate diagnostic.
rust
Theme-adaptive SVG diagrams
The `:::diagram` directive passes hand-authored SVG through unescaped. Authors write `fill="var(--blue)"` directly in the markup instead of a literal hex, so an architecture diagram re-colors when the visitor switches themes — same mechanism as every other themed element on the site.
dist
Release-mode JWT secret guard
A release build refuses to start if `JWT_SECRET` isn't set explicitly — checked once at startup, not lazily per request.
dist
Origin-scoped CORS in release
A release build restricts `Access-Control-Allow-Origin` to the configured site URL — the API doesn't answer cross-origin requests from anywhere else.
dist
CSP with a report endpoint, not just a header
The Content-Security-Policy header runs Report-Only while the inline theme-boot script still needs `unsafe-inline`, and every violation POSTs to a real backend endpoint instead of being silently dropped — observability before enforcement.
dist
Argon2 password hashing, owner-rotatable
The admin password is hashed with argon2 and changeable from the settings UI with the current password required — no direct database access needed to rotate it.
micro
A boot script that mirrors resolveTheme and the font catalog exactly
Theme and font resolution each run twice: once as a pure function for the rest of the app, and once as an inline `<script>` string that applies the identical precedence before first paint — no flash of the wrong theme or font. A vitest suite executes the generated boot script in a sandboxed DOM stub and asserts it agrees with the pure resolver across the whole override matrix.
micro
Server and client resolve site identity from the same data
The root layout fetches the site theme, fonts, brand, custom themes, and topic colors server-side, threading them into both the SSR render and an inline boot script — the very first paint already shows the owner's configured site, not a default a client-side effect swaps in afterward.
micro
One admin editor, generic over directive types
Inserting a `:::` block in the TipTap editor reads from one template array — adding a new directive to the insert menu and slash-command list is a one-line addition, no new editor plumbing per directive type.
perf
One cache abstraction, several settings blobs
`CachedSiteSettingsRepository` wraps `SiteSettingsRepository` with an in-process moka cache, invalidated synchronously on write — theme, font, and site-identity reads never round-trip to Postgres on a warm cache.
dist
Two analytics sources, one dashboard
The Rust backend only sees API traffic behind the reverse proxy, so `request_logs` (axum middleware) and `page_views` (first-party frontend beacons) feed the same analytics dashboard from two different vantage points — infra metrics from one source, content metrics from the other.
sysdes
Revision history without a version-control system
`document_revisions` snapshots the full document — markdown, metadata, status — on every admin save, keyed to the document's stable id. Browsing, diffing, and restoring a past version is a query, not a git operation.
sysdes
Retrieval-augmented search over the content corpus
Every save re-embeds the document unconditionally into `document_embeddings`, chunked and stored with a self-contained retrieval payload — a title edit's next save keeps the index fresh for free, and the similarity search filters on the currently-configured embedding model so a provider change can't silently mix incompatible vectors.
Data model
Sixteen tables across five areas: content (documents, tags, revisions, comments,
reactions, embeddings), site-wide config, auth, growth (subscribers, notifications,
media), and analytics/AI. site_settings alone carries five keys — site_theme,
site_fonts, site_profile, custom_themes, topic_colors.
Tech stack
axum
HTTP API, multipart uploads, websockets
tokio
async runtime
tower + tower-http
tracing · compression · CORS · security headers
sqlx
Postgres, runtime-checked queries, migrations
comrak
GFM markdown to HTML
syntect
classed-span syntax highlighting
argon2
admin password hashing
jsonwebtoken
stateless JWT auth
lettre
SMTP mailer — newsletter + notify-on-publish
moka
in-process cache in front of settings + content reads
thiserror
typed error enums per layer
uuid (v7)
time-ordered IDs
Next.js 16
App Router, SSR + RSC
React 19
admin editor + public site components
TipTap
markdown-serializing WYSIWYG editor
Vitest
pure-logic unit tests (theme resolution, custom themes)
sass
globals.scss — theme tokens, all page/component styles
Decisions worth discussing
storage
Runtime-checked sqlx queries over the compile-time query! macros: correctness comes from integration tests on the repository, not from coupling every build to a live database schema. Iteration stays fast; migrations stay the one schema authority.
auth
Stateless JWT instead of a server-side session store: nothing to run, back up, or scale beyond the API itself. The tradeoff — no server-side revocation before expiry — is accepted for a single-owner admin with a short token lifetime.
theming
Topic colors are a fixed hex per topic, not derived from the active theme: a topic's color is its identity across every list and badge, so it has to stay constant when the visitor switches themes, unlike the page accent, which is meant to shift.
infrastructure
Node 24 LTS in Docker and CI only, not pinned in local dev: the runtime that actually ships is what's version-locked; a developer's local Node just needs to be new enough, not identical.
architecture
Ports are defined only at real seams — `ContentRepository`, `Renderer`, `StorageBackend`, `AuthProvider`, `SiteSettingsRepository` — each with one concrete adapter today and room for a second (S3 storage, a different mailer) without touching the domain or API layers.
caching
One generic `CachedSiteSettingsRepository` wraps the `SiteSettingsRepository` port and fronts every settings blob — theme, fonts, brand/social identity — with an in-process moka cache, invalidated on write.
identity
Brand text, logo/favicon, and social links live in `site_settings`, applied before first paint like the theme — the owner edits the site's identity from the admin UI, not the source.