Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

Nuxt & VueLesson

Virtual modules

Some data is known before the app runs: it follows from files in the repo. Code can work it out again on every request, or someone can generate a file and commit it, and then keep that copy in step with its source forever. A Nuxt module offers a third way: work the data out each time Nuxt prepares, builds or starts in development, and give it to the code as an ordinary import.

Know Nuxt modules and templates already? Skip to In Terrarium.

Outside the box · General

The concept

A Nuxt module is a function that Nuxt runs when it starts, in development or for a build. It is written with defineNuxtModule, and its setup(options, nuxt) can read the config, add files and change options before the app is compiled. A project's own modules can sit in its modules/ folder, where Nuxt registers every modules/*.ts without being told, after the ones nuxt.config.ts lists.

One thing a module can add is a template: a file whose text the module computes. addTemplate({ filename, getContents }) puts it in Nuxt's virtual file system, and write: true also writes it to the build folder, .nuxt/. App code imports it from #build/<filename>, or under a shorter alias the module adds to nuxt.options.alias. The data was computed at build time, so the browser receives plain values and none of the code that made them.

A computed file has no types of its own. addTypeTemplate writes a .d.ts beside it and adds it to the types Nuxt generates for the app, so vue-tsc and the editor know what the import holds.

Full referenceNuxt Kit: Templates

Looking inside

In Terrarium

Every page needs to turn a and a into the keys of that Space's . modules/routing.ts works out that answer at build time from the . It expands the manifests with the same expand(loadManifests()) that builds the collections, turns the result into a map from Tenant to Space to Collection to key, and writes that map twice from one JSON string. First as data:

modules/routing.ts ↗26–41as of
26    const mapJson = JSON.stringify(map, null, 2)
⋯    // …
30    const template = addTemplate({
31      filename: 'routing.mjs',
32      write: true,
33      getContents: () =>
34        [
35          '// Build-time routing data (ADR-0014). Written to .nuxt/ at prepare/build time.',
36          '// Derived from tenant manifests via expand(). Do not edit — change the manifests.',
37          '',
38          `export const routingMap = ${mapJson}`,
39          '',
40        ].join('\n'),
41    })

Then as its type, with the alias that names the data file:

modules/routing.ts ↗55–66as of
55    addTypeTemplate({
56      filename: 'routing.d.ts',
57      getContents: () =>
58        [
59          `export declare const routingMap: ${mapJson}`,
60          '',
61        ].join('\n'),
62    })
⋯    // …
66    nuxt.options.alias['#routing'] = template.dst

Every value in the map is a string, which makes the JSON text a valid TypeScript type as well, one that names each key exactly, such as "atlas_canopy_pages", not just string. shared/routing.ts imports the map from #routing and derives from its type, per Tenant, the union of keys a page may query. A Tenant's page gets results typed with that Tenant's own fields, with no cast.

That import is a plain static import, not a composable. Tools that run outside Nuxt need the alias too, pointed at the generated files: Vitest's vitest.config.ts maps #routing to .nuxt/routing.mjs, and tsconfig.node.json, read by tsc, maps it, and #catalog, to their .d.ts files.

Its sibling modules/catalog.ts writes #catalog, the , the same way, with a fixed interface as its type (Cross-tenant reads). The two reach Nuxt differently. ./modules/routing is named in the modules list of nuxt.config.ts; the catalog module is not, and Nuxt registers it from the modules/ folder.

Why it's built this way

  • No stale copy. A committed copy of the map would need a regenerate step and a drift check, and could still fall behind the manifests. Terrarium builds the map on every prepare, build or dev start instead, so no copy can go stale. The cost: a PR diff doesn't show the map; read the manifest diff. ADR-0007 (docs/adr/0007-derived-files-built-not-committed.md)
  • Plain data for the browser. The browser needs the map, but working it out there would ship every manifest and zod to the client. Building it ahead of time ships only plain values. ADR-0014 (docs/adr/0014-build-time-virtual-routing-module.md)
  • One expansion. The collections, the routing map and the smoke test's entry routes all come from one expand(loadManifests()), so none can disagree with the manifests or each other, and Tenant isolation rests in one place. ADR-0014 (docs/adr/0014-build-time-virtual-routing-module.md)
  • A plain import. Reaching the map through a Nuxt composable would tie the resolver to a running Nuxt app. A static import of #routing keeps shared/routing.ts plain code that a unit test runs without Nuxt. ADR-0014 (docs/adr/0014-build-time-virtual-routing-module.md)
  • JavaScript for the server. The server bundler of (Nitro server engine) reads plain JavaScript with no TypeScript step, so the data template is .mjs and its types sit in a separate .d.ts. modules/routing.ts

What to watch for

  • Tools outside Nuxt can see a stale map. pnpm test and pnpm typecheck run nuxt prepare first; run alone, pnpm exec vitest, tsc -p tsconfig.node.json and the editor read what the last nuxt prepare wrote. Run it after a rebase or a new Tenant: ADR-0014 (docs/adr/0014-build-time-virtual-routing-module.md).
  • A new virtual import needs aliases outside Nuxt. addTypeTemplate reaches only Nuxt's own type check. Give the import a paths entry for the second pass (Type checking) and, if a unit test imports it, an alias in vitest.config.ts.
  • Only the dev server watches the manifests. It restarts when an existing manifest changes, not for a new Tenant: Content collections.

Try it in your project

Give your app a build stamp with no code that runs at request time. Add modules/build-info.ts:

// modules/build-info.ts
import { addTemplate, addTypeTemplate, defineNuxtModule } from '@nuxt/kit'

export default defineNuxtModule({
  setup(_options, nuxt) {
    const info = JSON.stringify({ builtAt: new Date().toISOString() })
    const template = addTemplate({
      filename: 'build-info.mjs',
      write: true,
      getContents: () => `export const buildInfo = ${info}\n`,
    })
    addTypeTemplate({
      filename: 'build-info.d.ts',
      getContents: () => `export declare const buildInfo: ${info}\n`,
    })
    nuxt.options.alias['#build-info'] = template.dst
  },
})

Run nuxt prepare, open .nuxt/build-info.mjs, then import { buildInfo } from '#build-info' in a page and hover it in your editor to see the exact type.

Where next

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/virtual-modules