Nuxt ContentLesson
Multi-tenant isolation
When several sites share one content database, the worst bug is a page that shows another site's content. Filtering every query by a site column makes each query a chance to forget the filter. Terrarium puts each site's content in tables of its own instead, so a query for one site has nothing else to return.
Know how Nuxt Content stores collections already? Skip to In Terrarium.
Outside the box · General
The concept
Nuxt Content keeps your content in an SQLite database. When it builds, it reads each collection's files, creates one table for that collection with columns from its schema, and inserts one row per file. Nothing else shares that table.
A query starts from one collection. queryCollection takes the name you gave the collection in content.config.ts and builds SQL against that collection's table alone: where, order and the rest narrow the rows of that one table, and no method joins it to another. When such a query reaches the server, Nuxt Content checks the SQL again and refuses one that reads any table but its collection's. Two collections are as separate as two tables, so the collection is the smallest unit of content you can keep apart.
Full referenceNuxt Content docs: Database
In Terrarium
Every , and together become one collection, and so one table. Its name is a key that joins the three names:
173/** The collection key — also the SQLite table name and the unit of isolation (ADR-0001). */
174export function collectionKey(tenant: string, space: string, collection: string): string {
175 return `${tenant}_${space}_${collection}`
176}
So the Blog's David Space keeps its posts under blog_david_pages, and Nuxt Content names the table after the key, _content_blog_david_pages. Each row is one . How the keys are produced from each manifest is in Content collections.
Pages don't build keys; they look them up. The build writes a routing map from the manifests, and resolveSpaceRoute in shared/routing.ts, a plain function with no Nuxt in it, reads only the requested Space's entry in it. It returns that Space's keys, pagesKey for its pages and collections for the rest, with the page's path inside the Space, or nothing for an unknown Tenant or Space. A page inside a Space reaches it through useSpace (File routing); a page above any Space, such as the Blog's root page layers/blog/app/pages/t/blog/index.vue, calls it once for each Space it shows.
Reads across Tenants take their keys from a second map, the , and still query one table at a time: app/composables/catalog.ts runs one query per Collection and merges the rows in code.
tests/unit/expand.spec.ts checks that every Tenant, Space and Collection gets its own well-formed key and folder, and tests/unit/routing.spec.ts that a resolved route names only the requested Space's keys. The resolver is on the list.
Why it's built this way
- Isolation without a filter. Making every Tenant, Space and Collection one collection gives each its own table, so a query for one Space can't return another's Documents, and there is no filter to forget. The cost: it holds only if pages ask for the right key, which the next two bullets cover. ADR-0001 (docs/adr/0001-single-container-baked-multitenancy.md).
- Testable resolver. The lookup first lived in the catch-all page, where no test reached it and a type bug was found only by luck. It became a plain function that a unit test runs without Nuxt, and that a page above any Space can call directly. tests/unit/routing.spec.ts.
- Small reviewed core. A resolver bug could show one Space's Documents in another, so a person merges any change to it (Protected paths).
useSpacedecides nothing and keeps the Nuxt glue out of the resolver, so it merges like other code and the reviewed part stays small. The cost: even a type fix in the resolver waits for a person. ADR-0004 (docs/adr/0004-objective-safety-gate.md). - One repo, one container. The owner wanted one repo and one container for every Tenant; a database or container per Space was rejected as more to deploy, for isolation not needed. The cost: every build holds every Space, so a Space that must never ship beside another needs its own build. ADR-0001 (docs/adr/0001-single-container-baked-multitenancy.md).
What to watch for
- Separate is not secret. Every build holds every Space, and Nuxt Content serves each collection's dump at
/__nuxt_content/<key>/sql_dump.txtso the browser can load it. Anyone can fetch any Space's content; the tables only stop a page from showing it by mistake. - Take keys from the resolver, never from a string. A key typed into a page, such as
`blog_${space}_pages`, skips the resolver and its tests. Code that reads another Space callsresolveSpaceRoutefor it, as the Blog's network footer does in layers/blog/app/components/blog/Network.vue. - A rename moves the content. The key is made of the three names, so renaming a Tenant, Space or Collection gives it a new table and a new folder under
layers/<tenant>/content/, and a Space's or Tenant's rename changes its URLs too. - Reads across Tenants are opt-in. A Collection opts in by naming a shared ; one without a kind is never in the Catalog (ADR-0025, docs/adr/0025-cross-tenant-catalog-and-collection-kinds.md).
Try it in your project
Start your Nuxt Content dev server once, then list the tables in its database with Node's built-in SQLite module (Node 22.13 or later; it may print an ExperimentalWarning), by running this file with node tables.mjs from the project root. You should see one _content_<name> table per collection in your content.config.ts, plus Nuxt Content's own _content_info and _development_cache:
// tables.mjs
import { DatabaseSync } from 'node:sqlite'
const db = new DatabaseSync('.data/content/contents.sqlite')
console.log(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all())
Where next
- LessonContent collections
- LessonFile routing
- LessonContent database
- LessonCross-tenant reads
- JournalArchitecture & Deployment
Walkthroughs through this Lesson
- WalkthroughAdding a site
- WalkthroughFrom file to browser
Last updated · Latest source on GitHub ↗