You don’t need a CMS to start. This guide shows a clean, durable setup using Next.js with file‑backed Markdown (and an easy path to MDX later) so you can focus on writing and shipping.
Why you don’t need a CMS (yet)
- Ownership: Your content lives as files in git. No lock‑in, easy reviews, great diffs.
- Speed: Write, preview, ship. No admin UI, no DB, no migration scripts.
- Reliability: Pure static reads at build/runtime. Fewer moving parts means fewer incidents.
- Cost: $0 infra for content. Host anywhere.
When the blog becomes a team with editors, roles, and scheduled campaigns, you can add a headless CMS. Until then, files win.
What you’ll build
- Content as Markdown files under
src/content/blog/posts/ - Typed metadata registry in
src/content/blog/meta.tsfor title/summary/tags/dates - Dynamic post pages:
app/blog/[slug]/page.tsxrenders Markdown withreact-markdown+remark-gfm - Index with tag filters:
app/blog/page.tsx - SEO basics: JSON‑LD + stable canonical URLs
This guide mirrors the code you see on this site, so you can copy/paste and adapt quickly.
Directory structure
src/ content/ blog/ meta.ts # blog registry (slug, title, summary, date, tags) posts/ ship-your-own-tech-blog.md beyond-autocomplete.md app/ blog/ page.tsx # index with tag filters [slug]/page.tsx # individual post renderer
Step 1 — Content lives as Markdown
Start by placing a Markdown file at src/content/blog/posts/your-post.md. Keep the top‑level H1 out of the Markdown—your page renders the title from metadata so you don’t duplicate it. Use regular Markdown + GitHub Flavored Markdown (tables, checklists, strikethrough, etc.).
Writing tips
- Lead with value: A short 2–3 sentence intro that tells readers what they’ll learn.
- Use sectional headings:
##for major sections,###for subsections. - Include runnable code: Prefer
ts,tsx,bash,jsonfences. - Close with a checklist: Readers love a concrete sequence they can follow.
Step 2 — Add metadata in one place
Metadata powers titles, summaries, dates, tags, and static params. Keep it all in src/content/blog/meta.ts:
export type BlogPostMeta = { slug: string; title: string; summary: string; date: string; // ISO tags: string[]; }; export const posts: BlogPostMeta[] = [ { slug: "ship-your-own-tech-blog", title: "Run Your Own Tech Blog with NextJS + MDX", summary: "Simple patterns to own your content, deploy anywhere, and keep writing.", date: "2025-07-10", tags: ["oss", "career"], }, // ...other posts ]; export const allTags: string[] = Array.from(new Set(posts.flatMap((p) => p.tags))).sort();
Store the canonical slug here; use it to match the Markdown filename.
Step 3 — Render Markdown on the post page
Your post page reads the Markdown file from disk and renders it with react-markdown and remark-gfm. That’s all you need for fast, safe rendering:
// app/blog/[slug]/page.tsx import { promises as fs } from 'fs'; import path from 'path'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { notFound } from 'next/navigation'; import { posts } from '@/content/blog/meta'; export function generateStaticParams() { return posts.map((p) => ({ slug: p.slug })); } export default async function BlogPostPage({ params }: { params: { slug: string } }) { const post = posts.find((p) => p.slug === params.slug); if (!post) notFound(); const contentDir = path.join(process.cwd(), 'src/content/blog/posts'); const mdPath = path.join(contentDir, `${params.slug}.md`); let markdown: string | null = null; try { markdown = await fs.readFile(mdPath, 'utf-8'); } catch { markdown = null; } if (!markdown) notFound(); return ( <article className="prose prose-invert max-w-none"> <ReactMarkdown remarkPlugins={[remarkGfm]}> {markdown} </ReactMarkdown> </article> ); }
Keep the renderer small and observable. If a file is missing, notFound() cleanly renders your 404.
Step 4 — An index page with tag filters
The index reads posts from metadata and lets readers filter by tag. This stays static and cacheable while content changes via git commits.
// app/blog/page.tsx (excerpt) import { posts, allTags } from '@/content/blog/meta'; export default function BlogPage({ searchParams }: { searchParams: { tag?: string } }) { const selectedTag = (searchParams?.tag && allTags.includes(searchParams.tag)) ? searchParams.tag : 'all'; const filtered = selectedTag === 'all' ? posts : posts.filter((p) => p.tags.includes(selectedTag)); // render cards with title/summary/date and tag pills }
Step 5 — Basic SEO and shareability
Add structured data and stable URLs so posts share well and rank properly:
// app/blog/[slug]/page.tsx (inside component) const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || ''; const url = `${baseUrl}/blog/${post.slug}`; const jsonLd = { "@context": "https://schema.org", "@type": "Article", headline: post.title, description: post.summary, datePublished: post.date, dateModified: post.date, url, };
Set NEXT_PUBLIC_SITE_URL in .env.local (e.g., https://yourdomain.com).
If you maintain a sitemap and robots file (recommended), ensure your blog route is included. Example files live at app/sitemap.ts and app/robots.ts.
Optional — Upgrade to MDX later
If/when you want components inside posts (diagrams, callouts, interactive widgets), add MDX. Two common paths:
- Built‑in MDX via Next’s MDX plugin in
next.config.mjs next-mdx-remoteto compile per request or at build time
Start simple with Markdown; upgrade only when you have a concrete use case.
Authoring workflow that sticks
- Start a draft branch:
feat/post-slug - Write Markdown in
src/content/blog/posts/ - Preview locally:
pnpm devthen open/blog/your-slug - Open a PR: Review prose diffs like code. Ask a teammate for a 5‑minute sanity read.
- Ship: Merge to main → deploy triggers → post is live.
Common pitfalls
- Duplicating titles: Don’t put
# Titleinside the Markdown if your page renders the title from metadata. - Mismatched slugs: Keep
meta.tsslug identical to the file name. - Fancy Markdown extensions: Stick to GFM unless you’ve configured extra remark/rehype plugins.
- Broken absolute links: Prefer root‑relative links (
/blog/some-post) rather than hard‑coding domains.
Deployment options (all work well)
- Vercel: First‑class Next.js, previews on PRs, great DX.
- Netlify: Solid Next.js support and edge features.
- Cloudflare Pages: Fast global edge, good for static‑heavy sites.
All three deploy this pattern with zero additional infra. The blog is just files + routes.
A copy‑paste checklist
- Create
src/content/blog/posts/your-post.md - Add an entry to
src/content/blog/meta.tswith slug/title/summary/date/tags - Preview at
/blog/your-post - Add JSON‑LD and ensure
NEXT_PUBLIC_SITE_URLis set - Merge and deploy
See also
- Agent loops that produce real outcomes: /blog/beyond-autocomplete
- Reference coding agent architecture: /blog/build-a-coding-agent-ts
- Add RAG search to your site: /blog/rag-for-your-blog-open-source
Closing thought
Perfect is the enemy of published. Start with Markdown in git, ship consistently, and add sophistication only when it pays for itself.