Blog
Programming challenges, experiments, and build notes behind short videos.
Programming challenges, experiments, and build notes behind short videos.

When I set out to build this blog, I had a few non-negotiable requirements: I wanted full control over the frontend, a clean editing experience for content, and the ability to deploy updates without rebuilding the entire site. The answer turned out to be a headless CMS architecture — Next.js on the frontend, Strapi on the backend.
Here's how I put it all together, and what I learned along the way.
The headless CMS pattern separates your content from your presentation layer. Strapi handles content management — giving you an admin panel, a REST API, user roles, and media uploads out of the box. Next.js handles rendering — with server components, static generation, and incremental revalidation built in.
This separation means you can:
Strapi v5 is a significant upgrade. The setup is straightforward:
npx create-strapi-app@latest my-cms --quickstartOut of the box you get an admin panel, a SQLite database (swap to PostgreSQL for production), and a REST API. The key things to configure early:
find and findOne on your content typesThe content model is the backbone of your blog. Here's what I landed on for the Post content type:
Post
├── title (string, required)
├── slug (string, unique)
├── excerpt (text)
├── content (rich text / HTML)
├── preview_image (media, single)
├── cover_image (media, single)
├── reading_time (integer)
├── difficulty (enum: easy, medium, hard, chaos)
├── pinned (boolean)
├── featured (boolean)
├── seo (component: meta_title, meta_description)
├── categories (many-to-many → Category)
└── tags (many-to-many → Tag)Category and Tag are simple content types with , , and fields, plus a many-to-many relation back to Post.
titleslugdescriptionOne lesson learned: design your SEO fields as a reusable component in Strapi. This way you can attach the same SEO component to any content type later without duplicating fields.
With the App Router, I split responsibilities cleanly:
page.tsx files are Server Components — they fetch data and pass it as propsblog.tsx) handle interactive UI with "use client"The project structure looks like this:
src/app/
├── layout.tsx # Root layout
├── (root)/page.tsx # Home — fetches latest posts
├── blog/
│ ├── page.tsx # Blog listing — fetches all posts
│ ├── blog.tsx # Client UI for the listing
│ └── [slug]/
│ ├── page.tsx # Post detail — fetch + generateMetadata
│ └── post-details.tsx # Client UI for the postThis pattern keeps data fetching on the server (no client-side API calls, no exposed tokens) while still allowing rich interactivity in the UI layer.
Strapi's REST API is clean, but complex queries — especially with nested population and filtering — need careful query string construction. I use axios for the HTTP client and qs for building query strings:
import axios from "axios";
import qs from "qs";
const cms = axios.create({
baseURL: process.env.NEXT_PUBLIC_CMS_API_BASE_URL,
headers: {
Authorization: `Bearer ${process.env.CMS_API_TOKEN}`,
},
});
const query = qs.stringify({
filters: { status: { $eq: "published" } },
populate: ["categories", "tags", "seo", "preview_image", "cover_image"],
sort: ["createdAt:desc"],
}, { encodeValuesOnly: true });
const { data } = await cms.get(`/api/posts?${query}`);The qs library is essential here — Strapi uses bracket notation for nested filters and populate, and manually building those query strings is error-prone.
Important: the CMS_API_TOKEN is a server-only environment variable. It never gets bundled into client-side code because it's only used in Server Components.
Strapi's rich text editor outputs HTML. The challenge is rendering that HTML as proper React components with your design system's styling. I built a small parser that maps HTML tags to Chakra UI components:
// Simplified example
function parseNode(node: Element): React.ReactNode {
switch (node.tagName) {
case "H2":
return <Heading size="xl">{children}</Heading>;
case "P":
return <Text mb={4}>{children}</Text>;
case "PRE":
return <CodeBlock>{children}</CodeBlock>;
case "A":
return <Link href={href} color="blue.500">{children}</Link>;
// ... more mappings
}
}This approach gives you full control over styling and lets you add features like syntax highlighting, link previews, or custom embed components without changing anything in the CMS.
Static generation is fast but stale. Server-side rendering is fresh but slow. Incremental Static Regeneration (ISR) gives you both — pages are statically generated, then revalidated in the background on a timer.
In Next.js App Router, it's a single line:
// In page.tsx
export const revalidate = 60; // Revalidate every 60 secondsThis means:
Without this, Next.js caches pages at build time forever. I learned this the hard way when I published a post in Strapi and it didn't show up on the site until the next Docker build.
The deployment setup uses a multi-stage Docker build for a minimal production image:
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_CMS_API_BASE_URL
ENV NEXT_PUBLIC_CMS_API_BASE_URL=$NEXT_PUBLIC_CMS_API_BASE_URL
RUN npm run build
# Stage 3: Run
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]One gotcha: NEXT_PUBLIC_ environment variables are inlined at build time, not runtime. Since .env is in .dockerignore (as it should be), you need to pass these as Docker build args. This tripped me up for a while — the CMS URL was undefined in production because it wasn't available during the Docker build.
For deployments, I use a simple Makefile with a make deploy target that builds the new image first, then swaps containers — giving near-zero downtime:
deploy:
docker compose build
docker compose up -dLooking back, a few things I'd change or recommend:
The headless CMS pattern isn't just a trendy architecture choice — it genuinely makes content management more flexible while keeping your frontend code clean and performant. If you're building a blog or content-driven site, give Next.js + Strapi a shot.
No featured posts yet. Check back soon!