Skip to content

Nuxt Content v3 in practice — typed collections for a multilingual blog

How we rebuilt this blog on Nuxt Content v3 — two typed collections, one shared schema, and an elegant translation convention.

Yann LerouxYann LerouxFrontend Engineer

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
    • authors/
      • ava-martin.md
      • yann-leroux.md

Every file starts with frontmatter. The schema below is the only contract between an author and the site:

content.config.ts (excerpt)
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 approachSame-slug convention
Translation linkManual "related content" fieldA filename
Broken translation UX404 from stale linksImpossible — the link only renders when the file exists
hreflang accuracyEditor's disciplineMechanical

Querying per locale

The composable hides the collection choice behind the active locale:

app/composables/useBlog.ts (excerpt)
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

  1. Write the file

  2. 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.

  3. Check the card

  4. The home page, the category page and the author page pick the article up immediately; reading time is computed from the parsed body.

  5. Translate (optional)

  6. 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:

nuxt nuxt-content i18n typescript
Yann Leroux

Written by

Yann Leroux

Frontend Engineer

Yann covers the Vue and Nuxt ecosystem, design systems and CSS. He maintains a few open-source UI utilities and cares deeply about typographic detail.