Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

Nuxt & VueLesson

File routing

A site whose pages all share one layout needs only one page file: a catch-all that reads the address and fetches what it names. A site that wants a few of its pages to look different then needs a way to let a more specific file take over, without a list of exceptions anyone must keep in order. Nuxt's file routing gives both.

Know Nuxt file routing already? Skip to In Terrarium.

Outside the box · General

The concept

Nuxt makes a route for every file in app/pages/. The file's path is the route's path: about.vue answers /about, and an index.vue answers its folder's own path, so app/pages/index.vue is /. A part of the name in square brackets is a parameter: posts/[id].vue answers /posts/1 and /posts/hello, and the page reads the value from useRoute().params.id. Folder names work the same way, so [tenant]/[space]/ holds two parameters.

A file named [...slug].vue is a catch-all. It matches every path below its folder, however deep, and its parameter holds the segments as an array: /hello/world gives ['hello', 'world']. With no segment left, the parameter is absent, so the catch-all also answers its folder's own path when no index.vue sits beside it.

Several pages often match one address. Nuxt's routing is built on Vue Router, which ranks routes by how specific their path is, not by the order they were found in: a fixed segment beats a parameter, and a parameter beats a catch-all. So posts/new.vue answers /posts/new even though posts/[id].vue matches it too. Pages from every layer join the same ranking, as if they sat in one app/pages/.

To send the visitor somewhere else from code, a page calls navigateTo(). During server rendering it answers with a redirect; in the browser it moves to the new route. Its result must be awaited or returned.

Full referenceNuxt docs: pages/ directory

Looking inside

In Terrarium

Every Terrarium site lives under /t/<tenant>/<space>/…. The Platform, the code outside any layer, answers all of those addresses with one catch-all, app/pages/t/[tenant]/[space]/[...slug].vue. It turns the two parameters into one and one with useSpace, then reads that Space's at the remaining path with useAsyncData (Data fetching):

app/pages/t/[tenant]/[space]/[...slug].vue ↗7–14as of
7const route = useRoute()
8const tenant = String(route.params.tenant)
9const { space, path, atRoot, pagesKey, collections } = useSpace(tenant)
⋯// …
13const { data, status, error } = await useAsyncData(route.path, async () => {
14  const page = await queryCollection(pagesKey).path(path).first()

useSpace, in app/composables/space.ts, is the one lookup every page inside a Space starts with. It is a Vue composable, a function a component calls from <script setup>: it reads the route, asks the resolver resolveSpaceRoute for that Space's keys, and throws a 404 when the Tenant or Space doesn't exist. What the keys are, and why that keeps Spaces apart, is in Multi-tenant isolation.

The slug may be missing, and then the same file renders each Space's front page, where it lists the Space's other Collections. A Tenant with no page files of its own still gets every address its declares, plainly styled.

A Tenant that wants its own look adds pages to its layer under app/pages/t/<tenant>/. Their first segment is the fixed name of the Tenant where the catch-all has [tenant], so they outrank it for that Tenant's addresses and no other. The Journal's folder, layers/journal/app/pages/t/journal/, shows three levels of it: [space]/index.vue takes each Space's front page, [space]/[...slug].vue takes every other Document, and fixed pages such as [space]/skills.vue outrank the Journal's own catch-all. An override reads its Space through the same useSpace as the catch-all, with the Tenant's name written in:

layers/journal/app/pages/t/journal/[space]/[...slug].vue ↗18–23as of
18const route = useRoute()
19const { space, path, pagesKey } = useSpace('journal')
20
21const { data: page, error } = await useAsyncData(route.path, () =>
22  queryCollection(pagesKey).path(path).first(),
23)

The bare /t/<tenant> matches no Platform page, since the catch-all needs a Space. A Tenant answers it with an index.vue at the top of its folder, its root page. The Blog's shows one feed of every 's posts; the Journal's only sends the visitor on to its main Space:

layers/journal/app/pages/t/journal/index.vue ↗1–5as of
1<script setup lang="ts">
2// Tenant-root route (ADR-0016): journal has no front door of its own, so its root
3// sends visitors to its main Space.
4await navigateTo('/t/journal/current')
5</script>

On the server that call answers /t/journal with a redirect, which the Journal's own end-to-end test checks with expectRedirect in layers/journal/tests/e2e/journal.spec.ts.

Why it's built this way

  • Path prefix, not host. Every request must reach one Tenant's Space, so the address starts with /t/<tenant>/<space>/. Subdomains need DNS setup and host-name code before a site works locally or in tests, and plain links and crawlers can't send a header. The cost: longer addresses, and a Tenant wanting its own domain needs a new way in. ADR-0006 (docs/adr/0006-runtime-routing-path-prefix.md).
  • Overrides are files. A Tenant restyles addresses with more specific page files in its layer, not by registering them in its manifest or routing code. Nuxt's ranking picks the page, so the shared code that decides a request's Space never changes; the override uses the same lookup. The cost: nothing lists overridden addresses. layers/journal/app/pages/t/journal/.
  • One door, no copies. Pages copied the same steps (read the address, look up the Space, 404 if none), and the copies drifted: one hid a type error, one showed a missing Space a landing page. useSpace now holds the steps, so there is one place to get right. The cost: every Space page must use it. ADR-0006 (docs/adr/0006-runtime-routing-path-prefix.md).
  • Root page per Tenant. A root isn't a Space; faking one as an empty Space would put a contentless place into every list of Spaces. An earlier Platform page at /t/[tenant] skipped the shared lookup and picked whichever Space came first. So each Tenant answers its root from its own layer. The cost: a Tenant without one gets a 404 there. ADR-0016 (docs/adr/0016-tenant-root-layer-routes.md).

What to watch for

  • The 404 covers the Space, not the Document. useSpace throws only for an unknown Tenant or Space. For a missing Document, Nuxt matched the page, so the response is a 200 unless the page says otherwise. The catch-all calls setResponseStatus(404) when no Document exists at the path, and the Journal's override repeats that check; an override without it answers a missing Document with a 200.
  • Pass the Tenant's name as a literal. The catch-all passes a string, so its keys are a union over every Tenant. In a Tenant's own page, write the name, as in useSpace('journal'), or the fields its template reads won't type-check.
  • A fixed page hides a Document at its path. Once [space]/skills.vue exists, a Document whose path is /skills in any of that Tenant's Spaces can no longer be reached, because the fixed page always outranks the catch-all.
  • navigateTo doesn't end the script. The rest of <script setup> still runs after the call. The Journal's root page puts nothing after it.
  • A root page needs its own test. The smoke test in tests/e2e/smoke.spec.ts visits the Space addresses it derives from the manifests, and no manifest knows about a root page. Each Tenant tests its own, as the Journal does.

Try it in your project

In a Nuxt app, add app/pages/docs/[...slug].vue that prints useRoute().params.slug, then app/pages/docs/intro.vue. Open /docs/intro, /docs/a/b and /docs, and see which file answers each. Add app/pages/docs/[id].vue too: /docs/intro still goes to intro.vue, /docs/x to [id].vue, and /docs/a/b to the catch-all.

Where next

Walkthroughs through this Lesson

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/guide/nuxt-vue/file-routing