Tous les articles
ArchitectureTypeScript

Valider le frontmatter MDX avec Zod

Comment typer et valider le frontmatter de fichiers Markdown au build avec gray-matter et Zod, pour transformer une erreur de contenu en erreur de compilation.

8 juillet 20264 min

Un blog ou un portfolio alimenté par des fichiers Markdown repose sur une hypothèse fragile : que chaque fichier expose exactement les métadonnées attendues. Un date oublié, un featured écrit "true" au lieu de true, une catégorie mal orthographiée — et le rendu casse en production, souvent silencieusement. La parade tient en une règle simple : valider le frontmatter au moment du build, pas à l'exécution. Voyons comment gray-matter et Zod s'en chargent.

Un schéma comme source de vérité

gray-matter sépare le frontmatter YAML du corps Markdown, mais il renvoie un objet non typé (data: { [key: string]: any }). C'est exactement là que Zod intervient : on décrit la forme attendue une seule fois, et on en dérive le type TypeScript.

import matter from "gray-matter";
import { z } from "zod";
 
const ArticleFrontmatterSchema = z.object({
  title: z.string(),
  description: z.string(),
  date: z.string(),
  category: z.string(),
  language: z.string(),
  featured: z.boolean().default(false),
  cover: z.string().optional(),
});
 
export type ArticleFrontmatter = z.infer<typeof ArticleFrontmatterSchema>;

Le point clé est z.infer. Le type ArticleFrontmatter n'est pas écrit à la main : il est déduit du schéma. Impossible que la validation et le type divergent, puisqu'ils sont le même objet. Ajouter un champ au schéma le propage automatiquement dans tout le code qui consomme un article.

Parser, c'est valider

La lecture d'un fichier se résume alors à deux étapes : extraire, puis parser.

function parseArticle(filename: string): Article {
  const filePath = path.join(articlesDir, filename);
  const fileContent = fs.readFileSync(filePath, "utf8");
  const { data, content } = matter(fileContent);
  const frontmatter = ArticleFrontmatterSchema.parse(data);
 
  return {
    ...frontmatter,
    slug: slugFromFilename(filename),
    rawContent: content,
    readingTime: calculateReadingTime(content),
  };
}

ArticleFrontmatterSchema.parse(data) fait tout le travail. Si un champ requis manque ou a le mauvais type, il lève une exception détaillée pointant le champ fautif. Sinon, il renvoie un objet typé — le any de gray-matter est neutralisé à la frontière. Deux détails valent d'être soulignés : default(false) remplit les champs absents (inutile de répéter featured: false partout), et optional() distingue clairement un cover facultatif d'un champ oublié.

Faire échouer le build plutôt que la prod

Avec Next.js en génération statique, generateStaticParams appelle ce parseur pour chaque fichier au moment du build. Conséquence directe : un frontmatter invalide fait échouer npm run build, avant tout déploiement. L'erreur de contenu devient une erreur de compilation — le meilleur endroit pour l'attraper.

Reste à décider quoi faire selon le contexte. À la génération de la liste complète, on veut la rigueur : parse casse le build. Mais pour une page consultée par slug, on peut préférer une dégradation douce plutôt qu'un crash :

export function getArticleBySlug(slug: string): Article | undefined {
  const filename = getArticleFilenames().find(
    (candidate) => slugFromFilename(candidate) === slug,
  );
  if (!filename) return undefined;
 
  try {
    return parseArticle(filename);
  } catch {
    return undefined;
  }
}

Ici, un fichier corrompu renvoie undefined, que la route traduit en notFound() — pas d'écran blanc. Pour une validation qui ne lève jamais et renvoie un résultat inspectable, safeParse est l'alternative : il retourne { success, data } ou { success, error }, pratique pour logger les fichiers problématiques sans interrompre le reste.

Ce qu'on gagne

Le bénéfice n'est pas seulement d'éviter des crashes. C'est de n'avoir qu'un seul endroit qui décrit ce qu'est un article : le schéma. Le type en découle, la validation en découle, l'autocomplétion en découle. Un champ nested (links: z.object({ github: z.string().optional() }).optional()), un tableau (tags: z.array(z.string())), une contrainte plus fine plus tard (z.string().datetime() sur la date) — tout s'ajoute au même endroit et se répercute partout, garanti par le compilateur.

Prochaine étape

Besoin d'un renfort fiable sur votre produit ?

Si vous cherchez un développeur full-stack capable de s'intégrer rapidement, de livrer proprement, et de faire avancer un périmètre sans alourdir l'existant, discutons-en.

Voir mon CV