i have been writing on this blog for years, and for most of that time it was a Hugo site: markdown files in a git repo, the PaperMod theme, a static build pushed to a host. it worked, and honestly it worked well. but i recently rewrote the whole thing as a headless setup, with Strapi as the CMS (content management system) and Next.js as the frontend, and enough people have asked "why" and "how" that it deserves a proper writeup(opus4.8 did the heavy lifting on UI).
what the site was
the old stack was simple:
- Hugo, a static site generator
- the PaperMod theme, which i genuinely love and did not want to lose
- posts and newsletter issues living as markdown files in
content/ - publishing meant committing to git and letting a build pipeline do the rest
what the site is now
the repo now holds two independent projects, each with its own package.json and no monorepo tooling(i need to give the monorepo tooling a serious thought later):
cms/: Strapi 5, written in TypeScript. it owns the content: four content types (post,newsletter,tag,site-setting) plus shared components for SEO (search engine optimization) metadata and social links. it runs on SQLite locally and PostgreSQL in production, and media uploads go to Linode Object Storage through the S3-compatible upload provider.client/: Next.js 16 on the App Router with React 19. it renders the public site: the home page,/posts,/tags,/newsletter,/archives,/search, plus the RSS feed, sitemap, and robots file.
to a non-technical person, what changed?
before, publishing a post meant editing a text file in a code repository and running the same machinery a software release would.
now there is an admin dashboard: i log in, write in an editor, hit publish, and the site updates. the reader sees the same blog either way; the difference is entirely in how the words get there.
keeping the design
this was a hard requirement.
i did not want the rewrite to look like a rewrite. the Next.js app is a pixel-faithful port of the old Hugo/PaperMod layout, and the laziest trick turned out to be the best one: i bundled the PaperMod CSS (cascading style sheets) more or less verbatim into styles/papermod.css and rebuilt the templates as React components around it.
header, footer, post cards, breadcrumbs, table of contents, share icons, the light/dark theme toggle; all of it matches the old site closely enough that regular readers should notice nothing.
migrating years of markdown
the scary part of any CMS move is the content. i had years of Hugo markdown with front matter, shortcodes, and images scattered across page bundles, and Strapi stores rich text as Blocks, a structured JSON format. so i wrote a migration pipeline instead of copy-pasting:
gray-matterparses each file's front matter (title, date, tags, description)remark-parseandremark-gfm(GitHub flavored markdown) turn the markdown body into a syntax tree, which a converter walks and rewrites as Strapi Blocks- every referenced image gets uploaded to the Strapi media library, and the entry is linked to it
- the whole thing is idempotent: rerunning it skips entries that already exist, and flags like
--dry-run,--limit,--only newsletter, and--forcemade it safe to iterate on - it writes a
redirect-map.jsonso old Hugo URLs can keep resolving after cutover
there is also a test-convert.ts script that converts a single post (or all of them) to Blocks JSON without touching the database, which caught most of the weird edge cases before they ever hit Strapi.
the small details that took the longest
as always, the headline architecture was the quick part. the details ate the weekends:
- fallback banners: posts without a cover image get a 1200x630 gradient banner generated at render time as an inline SVG (scalable vector graphics) data URI. the colors are seeded from a hash of the slug, so every post keeps its own gradient forever, and the Open Graph image route shares the same palette so the social card matches the on-page banner.
- syntax highlighting: Strapi's Blocks code node carries no language field, so the frontend auto-detects with highlight.js against a curated subset of about a dozen languages. better accuracy than the full 190-language build, and a much smaller bundle.
- search: a route serves a lightweight search index, and Fuse.js does fuzzy matching in the browser on
/search. no external search service needed for a blog this size. - dev seeders: a development-only script creates admin and test users, tags, posts, and newsletters into the local SQLite database, and refuses to run in production. it makes a fresh clone usable in minutes without importing the real content.
deploying it
production is one Docker image. a single root Dockerfile builds both services, and a small start script runs Strapi on port 1337 and Next.js on port 3000 in the same container. the Next build renders dynamically, so it does not need a running CMS at build time, which keeps the image build self-contained. docker-compose.yaml wires it up with PostgreSQL, and because media lives on Linode Object Storage, there is no media volume to babysit.
was it worth it?
for the reader, nothing changed, which was the goal. for me, writing now happens in an admin panel instead of a git client, newsletters are a real content type with their own structure instead of markdown cosplay, and the site is a codebase i can extend (the search, the generated banners, and the OG images simply were not going to happen in Hugo templates, at least not by me).
if your static site workflow still makes you happy, keep it; static sites are a gift. but if you have been fighting yours, a headless CMS with a thin frontend is a very buildable weekend-sized project, and you get to keep the design you already love.
Comments