This blog runs on Nuxt Content v3, and its multilingual content model fits in about forty lines of configuration. This article walks through the whole setup: the typed collections, the shared schema, the translation convention, and the querying patterns that make both locales feel native.
This is the English half of a translated pair. The French version lives at
the same slug under /fr — that pairing is the convention explained below.
The shape of the content
Articles live next to the code, in two directories — one per language:
- 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/
Every file starts with frontmatter. The schema below is the only contract between an author and the 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()
})
One schema, two collections. blog_en and blog_fr are defined with
the same zod object. If a French article forgets its category, the
build fails — not the layout.
The translation convention
Same filename, both directories: the two files are translations of each other. Everything else follows from that single rule:
- the article page looks up its sibling and shows a locale switcher only when a sibling exists;
- hreflang alternates are emitted only between existing translations;
- the sitemap groups the pair under one entry with language alternates.
What this replaces
| Typical CMS approach | Same-slug convention | |
|---|---|---|
| Translation link | Manual "related content" field | A filename |
| Broken translation UX | 404 from stale links | Impossible — the link only renders when the file exists |
| hreflang accuracy | Editor's discipline | Mechanical |
Querying per locale
The composable hides the collection choice behind the active locale:
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)
}
Do not trust the file system at runtime. Drafts are filtered in code
(!p.draft), and direct access to a draft renders only in development.
Forgetting the filter in even one query route leaks drafts into sitemaps
and RSS.
Adding an article, end to end
Write the file
Check the card
Translate (optional)
Create content/blog/en/my-idea.md with complete frontmatter. If the
project is fresh, install dependencies first:
npm install
Then run the dev server — the collection types regenerate automatically.
The home page, the category page and the author page pick the article up immediately; reading time is computed from the parsed body.
Create content/blog/fr/my-idea.md with the same frontmatter keys. The
locale switcher and hreflang appear without any further code.
The authoring experience
Authors write Markdown with a small set of custom components — callouts, steps, tabbed code blocks, file trees, comparisons. They are documented in the MDC showcase that ships with the blog itself: