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
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:
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:
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
#routingkeeps 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
.mjsand its types sit in a separate.d.ts. modules/routing.ts
What to watch for
- Tools outside Nuxt can see a stale map.
pnpm testandpnpm typecheckrunnuxt preparefirst; run alone,pnpm exec vitest,tsc -p tsconfig.node.jsonand the editor read what the lastnuxt preparewrote. 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.
addTypeTemplatereaches only Nuxt's own type check. Give the import apathsentry for the second pass (Type checking) and, if a unit test imports it, an alias invitest.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
- LessonContent collections
- LessonFile routing
- LessonNuxt layers
- LessonAuto-imports
Last updated · Latest source on GitHub ↗