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.