Retour au blog

Un blog Markdown avec Next.js, sans CMS ni base de données

Un blog Markdown avec Next.js, sans CMS ni base de données

8 octobre 2026 — Next.js

Publié le

8 octobre 2026

Temps de lecture

3 min de lecture

Catégorie

Next.js

Mots-clés

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

(Intro)Un blog où chaque article est un fichier Markdown dans votre dépôt Git. Front matter, coloration syntaxique, images, vidéo et pages statiques avec l’App Router de Next.js.

Le blog que vous lisez n’a ni base de données ni CMS hébergé. Chaque article est un dossier du dépôt Git, avec un index.md et ses images. Pousser un commit construit le site et publie l’article. Voici comment faire la même chose avec l’App Router de Next.js.

Pourquoi du Markdown dans Git

  • Gratuit et rapide. Aucun abonnement à un CMS, aucune requête en base ; chaque page est du HTML statique.
  • Versionné. Chaque modification est un commit que vous pouvez relire et annuler.
  • Portable. Le Markdown survit à toutes les plateformes. Changer de framework, c’est changer le moteur de rendu, pas migrer du contenu.
  • Modifiable partout. L’éditeur web de GitHub, VS Code, ou un CMS basé sur Git comme Decap ou Sveltia si quelqu’un préfère un formulaire.

La contrepartie : les rédacteurs non techniques ont besoin d’un minimum de Git, ou d’un CMS basé sur Git devant.

L’arborescence

Un dossier par article garde les images à côté du texte qui les utilise — et les liens relatifs s’affichent aussi correctement dans l’aperçu GitHub :

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

Un article commence par un front matter YAML :

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.

Les dépendances

bash
npm install gray-matter remark remark-gfm remark-html highlight.js
  • gray-matter sépare le front matter du corps.
  • remark + remark-gfm analysent le Markdown avec les extensions de GitHub (tableaux, listes de tâches).
  • remark-html le transforme en HTML.
  • highlight.js colore les blocs de code au moment du build : les lecteurs ne téléchargent aucun outil de coloration.

Charger les articles

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 laisse passer le HTML brut des articles — pas de souci si vous êtes le seul auteur. Si d’autres personnes peuvent soumettre des articles, continuez à nettoyer.

Comme ce code ne tourne que dans des Server Components et generateStaticParams, fs ne pose pas de problème : il s’exécute au build, jamais dans le navigateur.

Les pages

L’index liste les articles ; la route de détail est statique pour chaque 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>
  )
}

La coloration syntaxique au build

Un petit plugin remark remplace chaque nœud de code par du HTML coloré :

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>`,
      }
    })
  }
}

Ajoutez-le avant remarkHtml avec .use(highlightCode), et importez un thème highlight.js comme highlight.js/styles/github-dark.css.

Images et vidéo

Next.js sert les fichiers statiques depuis public/, donc les médias des articles doivent s’y retrouver. Un minuscule script lancé avant dev et build copie tout ce qui n’est pas du 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"
  }
}

Ensuite, dans un autre plugin remark, réécrivez les URL d’images relatives (./cover.jpg) en /blog-media/<slug>/cover.jpg. Tant que vous y êtes, transformez les images qui pointent vers des fichiers .mp4 ou .webm en éléments <video controls>, et les liens YouTube en vidéos intégrées : les auteurs continuent d’écrire la syntaxe d’image Markdown habituelle.

Ajoutez public/blog-media au .gitignore : ce dossier est généré.

Publier

Connectez le dépôt à un hébergeur statique — Cloudflare Pages, Netlify ou Vercel — avec npm run build comme commande de build. Écrire un article, c’est alors :

  1. Créer un dossier et un index.md.
  2. Commiter et pousser.
  3. L’hébergeur construit et déploie en quelques minutes.

Les bonus SEO qui valent le coup

  • generateMetadata avec le titre, l’extrait, l’URL canonique et l’image de couverture de l’article comme image Open Graph.
  • Un sitemap.ts qui liste chaque article.
  • Du JSON-LD de type BlogPosting.
  • Des identifiants sur les titres, pour pouvoir créer des liens vers les sections.

Tout cela est traité dans le SEO technique pour l’App Router de Next.js.

Quand passer à autre chose

Si plusieurs personnes non techniques publient chaque jour, ont besoin d’articles programmés ou de circuits de validation, un CMS headless commence à être rentable. Pour un site personnel, un studio ou un blog produit avec quelques articles par mois, le Markdown dans Git est difficile à battre.

Newsletter

Recevez les nouveaux articles par e-mail

Un court e-mail à chaque nouvelle publication. Pas de spam, et désinscription possible à tout moment.

Un formulaire de contact serverless avec Cloudflare Workers et D1
Un formulaire de contact serverless avec Cloudflare Workers et D1
Article suivant