Rust · Next.js · Hexagonal-ish

genuine.dev — the site you're reading

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.

Language Rust + TypeScriptBackend axum · sqlx · PostgresFrontend Next.js 16 · React 19Auth Stateless JWT — no session storeTheming 7 DB-backed families + custom themes + fontsIdentity Brand, logo/favicon, socials — all owner-editableDeploy Single VPS · Caddy · GitHub Actions

Why build it from scratch

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

genuine.dev system architecture Visitor and owner traffic through Caddy to either the Next.js frontend or the axum JSON API, backed by Postgres, local-disk uploads, and an SMTP mailer. Visitor Owner / Admin HTTPS Caddy TLS termination · security headers · reverse proxy everything else /api · /feed.xml · /uploads Next.js frontend SSR + RSC · admin editor · theme boot script axum JSON API domain → app ports → infra adapters SSR fetch, API_INTERNAL_URL Postgres documents · site_settings · comments · media … SMTP mailer newsletter · notify-on-publish /uploads local disk
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.
Postgres
owns: content, settings, everything
sqlx runtime-checked queriesfull-text searchkey/JSONB settings
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.

render.rs
1fn render(&self, markdown: &str) -> Rendered {2    let with_code = self.preprocess_code_blocks(markdown);3    let (nodes, diagnostics) = directive::parse(&preprocessed);4    let html = render_nodes(&nodes, &mut diagnostics);5    Rendered { html, reading_min: reading_time(markdown), diagnostics }6}
Source of truth

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.

04
Directive-driven case studies
AST parser · classed-span highlighting · theme-adaptive SVG
"A real AST, not a scan-and-rescan preprocessor"

`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.

documents id uuid PK slug text UK metadata jsonb core content table tags id uuid PK slug text UK accent text? flat tag list document_tags document_id uuid FK tag_id uuid FK documents × tags join document_revisions id uuid PK document_id uuid FK metadata jsonb snapshot on save comments id uuid PK document_id uuid FK body text unauthenticated, flat reactions id uuid PK document_id uuid FK reactor text anonymous, deduped document_embeddings document_id uuid FK embedding vector(1024) model text RAG chunks, HNSW index site_settings key text PK value jsonb 5 keys — theme, fonts, profile, themes, topics users id uuid PK username text UK password_hash text single-owner admin media id uuid PK url text UK byte_size bigint uploads library subscribers id uuid PK email text UK status pending|confirmed double opt-in notifications id uuid PK kind text document_slug text? admin-only alerts request_logs id bigserial PK path text latency_ms int axum middleware page_views id bigserial PK path text visitor_hash text? first-party beacons ai_conversations id uuid PK title text updated_at timestamptz Cmd+K chat history ai_messages id uuid PK conversation_id uuid FK role system|user|assistant chat turns 1:N from documents

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.