Ce blog tourne avec Nuxt Content v3, et son modèle de contenu multilingue tient en une quarantaine de lignes de configuration. Cet article parcourt l'ensemble du dispositif : les collections typées, le schéma partagé, la convention de traduction et les requêtes qui rendent les deux locales naturelles.
Ceci est la moitié française d'une paire traduite. La version anglaise vit au même slug, sans préfixe — ce couplage est la convention expliquée ci-dessous.
La forme du contenu
Les articles vivent à côté du code, dans deux répertoires — un par langue :
- content/
- blog/
- en/
- nuxt-content-multilingual-blog.md
- go-context-patterns.md
- fr/
- nuxt-content-multilingual-blog.md
- tailwind-v4-design-system.md
- en/
- authors/
- ava-martin.md
- yann-leroux.md
- blog/
Chaque fichier commence par un frontmatter. Le schéma ci-dessous est le seul contrat entre un·e auteur·rice et le site :
const blogSchema = z.object({
date: z.coerce.date(),
updated: z.coerce.date().optional(),
category: z.enum(['engineering', 'nuxt', 'go', 'css', 'devops', 'tooling']),
tags: z.array(z.string()).default([]),
author: z.string(),
cover: z.string().optional(),
draft: z.boolean().default(false),
featured: z.boolean().default(false),
series: z.string().optional(),
seriesOrder: z.number().optional(),
canonical: z.string().optional()
})
Un schéma, deux collections. blog_en et blog_fr sont définies avec
le même objet zod. Si un article français oublie sa category, c'est le
build qui échoue — pas la mise en page.
La convention de traduction
Même nom de fichier, les deux répertoires : les deux fichiers sont des traductions l'un de l'autre. Tout le reste découle de cette règle unique :
- la page d'article cherche son jumeau et n'affiche le sélecteur de locale que lorsqu'un jumeau existe ;
- les hreflang alternates ne sont émis qu'entre traductions existantes ;
- le sitemap regroupe la paire sous une entrée unique, avec ses alternates de langue.
Ce que cela remplace
| Approche CMS typique | Convention du même slug | |
|---|---|---|
| Lien de traduction | Champ manuel « contenu lié » | Un nom de fichier |
| Traduction cassée | 404 depuis des liens obsolètes | Impossible — le lien ne se rend que si le fichier existe |
| Précision du hreflang | Discipline de l'éditeur | Mécanique |
Interroger par locale
Le composable cache le choix de la collection derrière la locale active :
export function blogCollectionName(locale: string): 'blog_en' | 'blog_fr' {
return locale === 'fr' ? 'blog_fr' : 'blog_en'
}
export async function useBlogPosts(locale: string): Promise<BlogPost[]> {
const items = await queryCollection(blogCollectionName(locale)).all()
return items.map(toPost).filter(p => !p.draft).sort(sortByDateDesc)
}
Ne faites pas confiance au système de fichiers à l'exécution. Les
brouillons sont filtrés en code (!p.draft), et l'accès direct à un
brouillon ne se rend qu'en développement. Oublier le filtre dans une seule
route de requête fait fuiter les brouillons dans les sitemaps et le RSS.
Ajouter un article, de bout en bout
Écrire le fichier
Vérifier la carte
Traduire (optionnel)
Créez content/blog/en/my-idea.md avec un frontmatter complet. Si le
projet est neuf, installez d'abord les dépendances :
npm install
Lancez ensuite le serveur de dev — les types de collection se régénèrent automatiquement.
La page d'accueil, la page de catégorie et la page auteur reprennent l'article immédiatement ; le temps de lecture est calculé depuis le corps parsé.
Créez content/blog/fr/my-idea.md avec les mêmes clés de frontmatter. Le
sélecteur de locale et le hreflang apparaissent sans aucun code
supplémentaire.
L'expérience d'écriture
Les auteur·ices écrivent en Markdown avec un petit jeu de composants — callouts, étapes, blocs de code à onglets, arbres de fichiers, comparaisons. Ils sont documentés dans la vitrine MDC qui accompagne le blog lui-même :