Back to the blog

Build a Markdown Blog in Next.js Without a CMS or Database

Build a Markdown Blog in Next.js Without a CMS or Database

8 October 2026 — Next.js

Published

8 October 2026

Reading time

2 min read

Category

Next.js

Tags

  • Next.js
  • Markdown
  • Blog
  • remark
  • Static Site

(Intro)A blog where every post is a Markdown file in your Git repo. Front matter, syntax highlighting, images, video and static pages with the Next.js App Router.

The blog you're reading has no database and no hosted CMS. Every post is a folder in the Git repository with an index.md and its images. Pushing a commit builds the site and publishes the post. Here's how to build the same thing with the Next.js App Router.

Why Markdown in Git

  • Free and fast. No CMS subscription, no database queries; every page is static HTML.
  • Versioned. Every edit is a commit you can review and roll back.
  • Portable. Markdown outlives any platform. Moving to another framework means changing the renderer, not migrating content.
  • Editable anywhere. GitHub's web editor, VS Code, or a Git-based CMS like Decap or Sveltia if someone prefers a form.

The trade-off: non-technical editors need either a little Git or a Git-based CMS in front of it.

The folder structure

One folder per post keeps images next to the text that uses them — and the relative links also preview correctly on GitHub:

text
content/blog/
  my-first-post/
    index.md
    cover.jpg
    diagram.png

A post starts with YAML front matter:

content/blog/my-first-post/index.md
---
title: My first post
date: 2026-10-08
excerpt: One or two sentences for the listing and the meta description.
cover: ./cover.jpg
tags: [Next.js, Markdown]
---

The post starts here.

Dependencies

bash
npm install gray-matter remark remark-gfm remark-html highlight.js
  • gray-matter splits front matter from the body.
  • remark + remark-gfm parse Markdown with GitHub's extensions (tables, task lists).
  • remark-html turns it into HTML.
  • highlight.js highlights code blocks at build time, so readers download no highlighter.

Loading the posts

lib/blog.ts
import fs from 'node:fs'
import path from 'node:path'
import matter from 'gray-matter'
import { remark } from 'remark'
import remarkGfm from 'remark-gfm'
import remarkHtml from 'remark-html'

const DIR = path.join(process.cwd(), 'content/blog')

export async function getAllPosts() {
  const slugs = fs.readdirSync(DIR).filter((name) =>
    fs.existsSync(path.join(DIR, name, 'index.md')),
  )
  const posts = await Promise.all(slugs.map(getPost))
  return posts.sort((a, b) => b.date.localeCompare(a.date))
}

export async function getPost(slug: string) {
  const file = fs.readFileSync(path.join(DIR, slug, 'index.md'), 'utf8')
  const { data, content } = matter(file)
  const html = String(
    await remark().use(remarkGfm).use(remarkHtml, { sanitize: false }).process(content),
  )
  return {
    slug,
    title: String(data.title),
    date: new Date(data.date).toISOString().slice(0, 10),
    excerpt: String(data.excerpt ?? ''),
    html,
  }
}

sanitize: false lets raw HTML in posts through — fine when you are the only author. If other people can submit posts, keep sanitizing.

Because this only runs in Server Components and generateStaticParams, fs is fine: it runs at build time, never in the browser.

The pages

The index lists posts; the detail route is static for every slug:

app/blog/[slug]/page.tsx
import { getAllPosts, getPost } from '@/lib/blog'

export const dynamicParams = false

export async function generateStaticParams() {
  return (await getAllPosts()).map(({ slug }) => ({ slug }))
}

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  )
}

Syntax highlighting at build time

A small remark plugin replaces each code node with highlighted HTML:

lib/highlight.ts
import hljs from 'highlight.js'
import { visit } from 'unist-util-visit'

export function highlightCode() {
  return (tree: any) => {
    visit(tree, 'code', (node: any, index, parent: any) => {
      const lang = node.lang && hljs.getLanguage(node.lang) ? node.lang : null
      const html = lang
        ? hljs.highlight(node.value, { language: lang }).value
        : hljs.highlightAuto(node.value).value
      parent.children[index!] = {
        type: 'html',
        value: `<pre><code class="hljs language-${lang ?? 'text'}">${html}</code></pre>`,
      }
    })
  }
}

Add it before remarkHtml with .use(highlightCode), and import a highlight.js theme such as highlight.js/styles/github-dark.css.

Images and video

Next.js serves static files from public/, so post assets need to end up there. A tiny script that runs before dev and build copies everything that isn't Markdown:

scripts/copy-blog-media.mjs
import fs from 'node:fs'
import path from 'node:path'

const SRC = 'content/blog'
const OUT = 'public/blog-media'
fs.rmSync(OUT, { recursive: true, force: true })

for (const slug of fs.readdirSync(SRC)) {
  const dir = path.join(SRC, slug)
  if (!fs.statSync(dir).isDirectory()) continue
  for (const file of fs.readdirSync(dir)) {
    if (file.endsWith('.md')) continue
    fs.mkdirSync(path.join(OUT, slug), { recursive: true })
    fs.copyFileSync(path.join(dir, file), path.join(OUT, slug, file))
  }
}
package.json
{
  "scripts": {
    "predev": "node scripts/copy-blog-media.mjs",
    "prebuild": "node scripts/copy-blog-media.mjs"
  }
}

Then, in another remark plugin, rewrite relative image URLs (./cover.jpg) to /blog-media/<slug>/cover.jpg. While you're there, turn images that point at .mp4 or .webm files into <video controls> elements, and YouTube links into embeds — authors keep writing plain Markdown image syntax.

Add public/blog-media to .gitignore; it's generated.

Publishing

Connect the repository to a static host — Cloudflare Pages, Netlify or Vercel — with npm run build as the build command. Now writing a post is:

  1. Create a folder and an index.md.
  2. Commit and push.
  3. The host builds and deploys in a couple of minutes.

SEO extras worth adding

  • generateMetadata with the post's title, excerpt, canonical URL and cover as the Open Graph image.
  • A sitemap.ts that lists every post.
  • BlogPosting JSON-LD.
  • Heading IDs, so sections can be linked to.

All of these are covered in technical SEO for the Next.js App Router.

When to outgrow it

If several non-technical people publish daily, need scheduled posts or editorial workflows, a headless CMS starts paying for itself. For a personal site, a studio or a product blog with a few posts a month, Markdown in Git is hard to beat.

Newsletter

Get new posts by email

A short email when something new is up. No spam, and you can unsubscribe at any time.

A Serverless Contact Form with Cloudflare Workers and D1
A Serverless Contact Form with Cloudflare Workers and D1
Next post →