oss
career

Run Your Own Tech Blog with NextJS + MDX

7/10/2025

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.ts for title/summary/tags/dates
  • Dynamic post pages: app/blog/[slug]/page.tsx renders Markdown with react-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, json fences.
  • 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-remote to 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 dev then 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 # Title inside the Markdown if your page renders the title from metadata.
  • Mismatched slugs: Keep meta.ts slug 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.ts with slug/title/summary/date/tags
  • Preview at /blog/your-post
  • Add JSON‑LD and ensure NEXT_PUBLIC_SITE_URL is set
  • Merge and deploy

See also

Closing thought

Perfect is the enemy of published. Start with Markdown in git, ship consistently, and add sophistication only when it pays for itself.