Nuxt ContentLesson
Content collections
A site with many sections, each in several variants, needs many content collections that differ only in a name and a folder. Written out by hand, that list grows with every section and goes wrong in ways nobody spots. Terrarium writes down only what each site holds, and lets code produce the list.
Know Nuxt Content collections already? Skip to In Terrarium.
Outside the box · General
The concept
Nuxt Content reads the files in your project and stores them in a database you query from your pages. A collection groups the files of one kind, such as blog posts or authors. You define collections in content.config.ts, at the project root, with defineCollection; once that file exists, only files that some collection matches are imported.
Each collection has a type. A page collection maps each file to one page of the site: Nuxt Content generates its path from the file name and adds the fields a page needs, such as title, description and the parsed body. A data collection holds structured records that pages read but that have no URL of their own, and its fields are all yours.
source says which files belong: a glob, or an object with include, exclude, a cwd folder to search from and, for pages, a path prefix. schema is a Zod object for the fields you add, and it is the source of the collection's TypeScript types. queryCollection('<name>') reads a collection.
content.config.ts is TypeScript that runs, not a static list, so the collections in it can be computed.
Full referenceNuxt Content docs: Define Content Collections
In Terrarium
Each states what it holds in one , layers/<tenant>/tenant.config.ts: its name, its , and its with their types and schemas. Tinkerfund's manifest names its Spaces, then the Collections every Space holds: its routed pages and the data Collections those pages read, each with a schema from Tinkerfund's own schemas.ts:
10export default defineTenant({
11 name: 'tinkerfund',
12 spaces: ['prod', 'qa'],
13 collections: {
14 pages: { type: 'page', source: '**/*.md', schema: page },
15 inventors: { type: 'data', source: '*.yml', schema: inventor },
16 categories: { type: 'data', source: '*.yml', schema: category },
17 comments: { type: 'data', source: '*.yml', schema: commentThread },
18 updates: { type: 'data', source: '*.yml', schema: updateLog },
19 promotions: { type: 'data', source: '*.yml', schema: promotion },
20 backer: { type: 'data', source: '*.yml', schema: backer },
21 shop: { type: 'data', source: '*.yml', schema: shop },
22 },
23})
shared/manifest.ts checks each manifest before anything is built from it. Its Zod schema is strict (Content validation), so a misspelt key fails with a message that names the field, and it refuses a data Collection with neither a schema nor a (Cross-tenant reads). Only the Collection named pages gets URLs (File routing), and the check holds both ways: a page Collection by any other name fails, and so does a pages Collection that isn't a page one.
Then expand() in shared/expand.ts multiplies it out: one Nuxt Content collection for every Tenant, Space and Collection. Each gets a key such as tinkerfund_prod_pages and a folder such as layers/tinkerfund/content/prod/pages/. Tenant, Space and Collection names may hold only lowercase letters and digits, so a key always splits back into its three parts. expand() fails the build if two collections share a key.
content.config.ts holds no list of collections. It turns the expansion into defineCollection calls each time Nuxt Content loads it:
20export default defineContentConfig({
21 collections: Object.fromEntries(
22 expand(loadManifests()).map((c) => [
23 c.key,
⋯ // …
29 c.type === 'page'
30 ? defineCollection({
31 type: 'page',
32 source: { cwd: dir(c.cwdRel), include: c.include, prefix: '/' },
33 schema: c.schema,
34 })
cwd points each collection at its own folder, and prefix: '/' keeps a page's path relative to its Space. The same expansion builds the routing map in modules/routing.ts, which turns an address into one Space's keys.
Why it's built this way
- Generated, not hand-written. By hand, the list of collections would be one shared file every Tenant edits, prone to merge conflicts, where one slip breaks every Tenant. So each Tenant writes a small and tested code multiplies it out, one collection per Space (Multi-tenant isolation says why). The cost: one more layer above Nuxt Content. ADR-0002 (docs/adr/0002-manifest-driven-config-generation.md).
- Checked manifests. Each manifest is checked before anything is built from it, so a misspelt key fails at once, naming the field. Unchecked, it could be silently ignored or fail later with a confusing Nuxt build error. The cost: a manifest can use only the keys the shared schema knows, so a new kind of setting changes that schema first. ADR-0002 (docs/adr/0002-manifest-driven-config-generation.md).
- Built at load time.
content.config.tswas once generated and committed, and PRs still failed when a manifest edit skipped regenerating it. Building the list when the config loads makes a stale list impossible. The cost: no file shows the expanded list, and the design relies on Nuxt Content letting the config import the manifests. ADR-0013 (docs/adr/0013-content-config-built-from-manifests.md). - Only
pageshas URLs. Each Space gets one set of addresses, so the URL scheme, route lookup and smoke test stay simple; everything else is data pages read. ApageCollection under another name once made the smoke test check addresses routing never served; now the check ties type to name. The cost: anything needing its own address must be a page inpages. ADR-0006 (docs/adr/0006-runtime-routing-path-prefix.md).
What to watch for
- Declare collections only in the manifest. A collection defined anywhere else, such as in a Tenant's layer, skips
expand()and its check that every key is unique, the backstop for one table per Space. - A page schema holds only the extras. The
pagetype already addstitle,description,bodyand the rest, so a manifest'spagesschema declares only the Tenant's own fields. - The dev server misses manifest edits on its own. Nuxt Content restarts when
content.config.tschanges, not when a file it imports does. modules/routing.ts adds the manifests andshared/expand.tsto Nuxt's watch list, which closes that gap for existing Tenants. A brand-new layer folder isn't watched, so restart the dev server after adding a Tenant.
Try it in your project
If your content.config.ts repeats nearly the same defineCollection call, say once per language, generate the calls from a list instead, so adding a language is one more entry:
// content.config.ts
import { defineCollection, defineContentConfig } from '@nuxt/content'
const locales = ['en', 'de']
export default defineContentConfig({
collections: Object.fromEntries(locales.map((locale) => [
`docs_${locale}`,
defineCollection({ type: 'page', source: `${locale}/**/*.md` }),
])),
})
Where next
- LessonNuxt layers
- LessonMulti-tenant isolation
- LessonContent database
- LessonContent validation
- LessonCross-tenant reads
Walkthroughs through this Lesson
- WalkthroughAdding a site
- WalkthroughFrom file to browser
Last updated · Latest source on GitHub ↗