06 Root Component
In this section, we'll remove the hardcoded App.vue path by implementing a virtual module system, similar to how the real Nuxt framework resolves user components. The full code is available at 6-root-component.
The Problem
In the previous implementation (5-packages), we hardcoded the path to App.vue in the entry files:
import App from '../../../../playground/App.vue'This is problematic because:
- The path is relative to the nuxt package, not the user's project
- It won't work when the package is installed from npm
- Users can't customize their root component location
How Does Nuxt Framework Solve This?
The real Nuxt framework uses a template system and virtual modules:
1. Template Definition
Nuxt defines templates in packages/nuxt/src/core/templates.ts:
export const appComponentTemplate: NuxtTemplate = {
filename: 'app-component.mjs',
getContents: ctx => genExport(ctx.app.mainComponent!, ['default']),
}
export const rootComponentTemplate: NuxtTemplate = {
filename: 'root-component.mjs',
getContents: ctx => genExport(ctx.app.rootComponent!, ['default']),
}2. App.vue Resolution
The resolveApp function in packages/nuxt/src/core/app.ts discovers the user's components:
export async function resolveApp (nuxt: Nuxt, app: NuxtApp) {
// Resolve main (app.vue)
app.mainComponent ||= await findPath(
layerDirs.flatMap(d => [join(d.app, 'App'), join(d.app, 'app')])
)
app.mainComponent ||= resolve(nuxt.options.appDir, 'components/welcome.vue')
// Resolve root component
app.rootComponent ||= await findPath([
'~/app.root',
resolve(nuxt.options.appDir, 'components/nuxt-root.vue')
])
}3. Virtual File System
The virtual plugin in packages/nuxt/src/core/plugins/virtual.ts resolves #build/ imports:
load: {
filter: { id: PREFIX_RE },
handler (id) {
const key = withoutQuery(withoutPrefix(decodeURIComponent(id)))
return {
code: nuxt.vfs[key] || '',
map: null,
}
},
}4. Usage in Entry and Components
The root component imports from #build/root-component.mjs in packages/nuxt/src/app/entry.ts:
import RootComponent from '#build/root-component.mjs'
const vueApp = createSSRApp(RootComponent)And nuxt-root.vue imports the app component:
<script setup>
import AppComponent from '#build/app-component.mjs'
</script>
<template>
<AppComponent v-else />
</template>For chibinuxt, we'll implement a simplified version using Vite's virtual module plugin.
Virtual Module Plugin
Create the plugin
Create a virtual module plugin that maps module IDs to generated code.
import type { Plugin } from 'vite'
const PREFIX = 'virtual:nuxt:'
export function virtual(vfs: Record<string, string>): Plugin {
return {
name: 'virtual',
resolveId(id) {
if (id in vfs) {
return PREFIX + id
}
return null
},
load(id) {
if (!id.startsWith(PREFIX)) {
return null
}
const idNoPrefix = id.slice(PREFIX.length)
if (idNoPrefix in vfs) {
return {
code: vfs[idNoPrefix],
map: null,
}
}
},
}
}Key points:
vfs(virtual file system) is a map from module ID to generated coderesolveIdmarks virtual modules with a special prefixloadreturns the generated code for virtual modules
Update Vite build
Update the build configuration to accept appComponent path and register the #app virtual module.
import { build as _build, mergeConfig, type InlineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { virtual } from './plugins/virtual'
export interface BuildOptions {
buildDir: string
clientEntry: string
serverEntry: string
appComponent: string
}
export const bundle = async (options: BuildOptions) => {
const { buildDir, clientEntry, serverEntry, appComponent } = options
// Virtual file system for #app module
const vfs: Record<string, string> = {
'#app': `export { default } from '${appComponent}'`,
}
const defaultConfig = {
plugins: [vue(), virtual(vfs)],
build: {
outDir: buildDir,
emptyOutDir: false,
rollupOptions: {
output: {
format: 'esm',
},
preserveEntrySignatures: 'exports-only',
treeshake: false,
},
},
define: {
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'true',
},
} satisfies InlineConfig
// ... client and server config remain the same
}Key changes:
- Added
appComponenttoBuildOptions - Created
vfswith#appmapping to re-export the user's App.vue - Added
virtual(vfs)plugin to the Vite config
Update Entry Files
entry.client.ts
Update to import from the virtual #app module.
import { createSSRApp } from 'vue'
import { createRouter } from './plugins/router'
// @ts-expect-error virtual module
import App from '#app'
const initApp = async () => {
const router = createRouter()
const app = createSSRApp(App)
app.use(router)
await router.isReady()
app.mount('#__nuxt')
}
initApp().catch(console.error)entry.server.ts
Same change for the server entry.
import { createSSRApp } from 'vue'
import { createRouter } from './plugins/router'
// @ts-expect-error virtual module
import App from '#app'
export default async (ctx: { url: string }) => {
const app = createSSRApp(App)
const router = createRouter()
router.push(ctx.url)
await router.isReady()
app.use(router)
return app
}Key point:
@ts-expect-erroris used because TypeScript doesn't know about the virtual module
Update loadNuxt
Update loadNuxt to pass the appComponent path to the vite builder.
import { join, resolve } from 'node:path'
import { createDevServer, createNitro } from 'nitro'
import { bundle } from '@nuxt/vite-builder'
export const buildDir = resolve(process.cwd(), '.nuxt')
export const loadNuxt = async () => {
const appComponent = resolve(process.cwd(), 'App.vue')
await bundle({
buildDir,
clientEntry: join(import.meta.dirname, '../app/entry.client.ts'),
serverEntry: join(import.meta.dirname, '../app/entry.server.ts'),
appComponent,
})
const nitro = await createNitro({
renderer: resolve(import.meta.dirname, './runtime/nitro/renderer.ts'),
})
const server = await createDevServer(nitro)
return { server }
}Key changes:
appComponentis resolved from the current working directory (user's project)- Passed to
bundle()as a new option
How It Works
When the build runs:
loadNuxt()resolvesApp.vuefrom the user's project directory- The path is passed to
bundle()asappComponent - The virtual plugin registers
#app→export { default } from '/path/to/playground/App.vue' - When Vite encounters
import App from '#app', it:- Calls
resolveId('#app')→ returnsvirtual:nuxt:#app - Calls
load('virtual:nuxt:#app')→ returns the re-export code - Follows the re-export to bundle the actual
App.vue
- Calls
Summary
By implementing virtual modules:
- No hardcoded paths: Entry files import from
#appinstead of relative paths - User-configurable: The root component is resolved from the user's project
- Similar to Nuxt: This pattern mirrors how real Nuxt handles template generation
- Extensible: The same pattern will be used for routes in the next section
The router still has hardcoded page imports - we'll fix that in the next section using the same virtual module pattern.
