08 Unbuild
In this section, we'll introduce a build step for the packages using unbuild. The full code is available at 8-unbuild.
Why do we need a build step?
In actual Nuxt, the framework is pre-built using unbuild before being published. We'll mirror this approach to make chibinuxt closer to the real Nuxt.
Why unbuild with mkdist?
unbuild supports two bundlers internally: rollup and mkdist. When you specify a directory as an input (like src/runtime/), unbuild uses mkdist which preserves the directory structure in the output.
This is crucial because nitro dynamically imports the renderer at runtime:
// packages/nitro/src/dev/server.ts
const renderer = await import(nitro.options.renderer!).then(m => m.default)The renderer path is passed from nuxt:
// packages/nuxt/src/core/nuxt.ts
const nitro = await createNitro({
renderer: join(distDir, 'core/runtime/nitro/renderer.js'),
})For this dynamic import to work, the built files must maintain their exact directory structure. If we used rollup bundling (single file input), all files would be bundled together and the path core/runtime/nitro/renderer.js wouldn't exist.
With mkdist:
src/core/runtime/nitro/renderer.ts→dist/core/runtime/nitro/renderer.js✅
Implementing the build step
Nitro build.config.ts
package: nitro
Create build.config.ts to configure unbuild for nitro:
import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
declaration: true,
entries: [
// Main entry - uses rollup (single file)
{ input: 'src/index.ts' },
// Runtime - uses mkdist (directory preserves structure)
{ input: 'src/runtime/', outDir: 'dist/runtime', format: 'esm' },
// Types - uses mkdist (directory preserves structure)
{ input: 'src/types/', outDir: 'dist/types', format: 'esm' },
],
alias: {
'nitro/runtime': './src/runtime/index.ts',
['nitro']: 'nitro',
},
externals: ['nitro/runtime', 'nitro/types'],
})Key points:
- Directory inputs use mkdist:
src/runtime/andsrc/types/preserve their structure - File inputs use rollup:
src/index.tsis bundled into a single file - Aliases are defined to resolve
nitro/runtimesubpath imports during build externalsprevents bundling of self-referencing imports
Vite build.config.ts
package: vite
Create build.config.ts to configure unbuild for vite:
import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
declaration: true,
entries: ['./src/index.ts'],
})Nuxt build.config.ts
package: nuxt
Create build.config.ts to configure unbuild:
import { defineBuildConfig, type BuildEntry } from 'unbuild'
export default defineBuildConfig({
declaration: true,
entries: [
// Core - uses rollup
{ input: 'src/index.ts' },
// App - uses mkdist (preserves structure for runtime imports)
{ input: 'src/app/', outDir: 'dist/app/', ext: 'js' },
// Runtime - uses mkdist (preserves structure for nitro renderer path)
{ input: 'src/core/runtime/', outDir: 'dist/core/runtime', format: 'esm', ext: 'js' },
// Bin - uses rollup
{ input: 'src/bin.ts' },
],
alias: {
['nuxt']: 'nuxt',
},
})Key points:
declaration: truegenerates TypeScript declaration files (.d.ts)- Directory entries preserve structure:
src/app/andsrc/core/runtime/use mkdist - The runtime directory structure is preserved so nitro can import
renderer.jsat the expected path ext: 'js'ensures output files have.jsextension for runtime imports
Update package.json
package: nuxt
Update package.json:
{
"bin": {
"nuxi": "dist/bin.mjs"
},
"exports": {
".": {
"import": "./dist/index.mjs"
}
},
"scripts": {
"prepack": "unbuild"
},
"devDependencies": {
"unbuild": "3.3.1"
}
}Key changes:
- The bin now points to
dist/bin.mjs(built JavaScript) instead of the TypeScript source - Added
exportsfield for proper module resolution - Added
prepackscript to run unbuild before publishing
Update nuxt.ts
package: nuxt
Update nuxt.ts to use distDir and built file paths:
import { dirname, join, resolve } from 'node:path'
import { createDevServer, createNitro } from 'nitro'
import { bundle } from '@nuxt/vite-builder'
import { scanPages, generateRoutesCode } from '../pages/scan'
// Get the dist directory (handles chunks subdirectory from unbuild)
let distDir = import.meta.dirname
if (distDir.match(/(chunks|shared)$/)) {
distDir = dirname(distDir)
}
export const buildDir = resolve(process.cwd(), '.nuxt')
export const loadNuxt = async () => {
const appComponent = resolve(process.cwd(), 'App.vue')
const pagesDir = resolve(process.cwd(), 'pages')
// Scan pages and generate routes code
const pages = await scanPages(pagesDir)
const routesCode = generateRoutesCode(pages)
await bundle({
clientEntry: join(distDir, 'app/entry.client.js'),
serverEntry: join(distDir, 'app/entry.server.js'),
buildDir,
appComponent,
routesCode,
})
const nitro = await createNitro({
// This path must match the mkdist output structure
renderer: join(distDir, 'core/runtime/nitro/renderer.js'),
})
const server = await createDevServer(nitro)
return { server }
}Key changes:
distDiris computed inline (handleschunksorsharedsubdirectories from unbuild)- Input paths changed from
.tsto.jssince we're now using pre-built files - Renderer path relies on preserved structure: The path
core/runtime/nitro/renderer.jsworks because mkdist maintains the directory structure
Update renderer.ts
package: nuxt
Update renderer.ts to resolve buildDir directly:
import { join, resolve } from 'node:path'
import { readFileSync } from 'node:fs'
import { defineRenderHandler } from 'nitro/runtime'
import { createRenderer } from 'vue-bundle-renderer/runtime'
import { renderToString } from 'vue/server-renderer'
const buildDir = resolve(process.cwd(), '.nuxt')
let renderer: ReturnType<typeof createRenderer>
const getRenderer = async () => {
if (renderer) return renderer
const createApp = await import(
join(buildDir, 'entry.server.js')
).then(m => m.default)
renderer = createRenderer(createApp, {
renderToString,
manifest: {},
})
return renderer
}
export default defineRenderHandler(async event => {
const { req, res } = event.node
if (req.url === '/entry.client.js') {
const code = readFileSync(
join(buildDir, 'entry.client.js'),
'utf-8',
)
res.setHeader('Content-Type', 'application/javascript')
res.end(code)
return { statusCode: 200, statusMessage: 'OK', headers: {} }
}
const renderer = await getRenderer()
const rendered = await renderer.renderToString({ url: req.url })
const body = renderHTML(rendered)
res.setHeader('Content-Type', 'text/html;charset=UTF-8')
return {
body,
}
})
// ... renderHTML and htmlTemplate functions remain the sameKey change:
buildDiris resolved directly usingprocess.cwd()since the renderer runs in the user's project context- No need for environment variables or importing from other modules
Create index.ts
package: nuxt
Create index.ts to export public API:
export { loadNuxt, buildDir } from './core/nuxt'Run the build
Run the following command in the root of 8-unbuild:
pnpm startThis command will:
- Build all packages (
pnpm buildrunspnpm --filter './packages/**' prepack) - Start the dev server (
pnpm devrunscd ./playground && pnpm dev)
Summary
By introducing unbuild with mkdist:
- Structure preservation: Directory inputs use mkdist which preserves the file structure, enabling dynamic imports with predictable paths
- Runtime path resolution: Nitro can dynamically import the renderer at
core/runtime/nitro/renderer.jsbecause mkdist maintains the exact path structure - Cleaner architecture: The package structure mirrors how published npm packages work
- Better performance: Pre-built JavaScript runs faster than transpiling TypeScript at runtime
- Production ready: The packages are now structured similarly to real Nuxt and Nitro
This build system will become more important as we add more features, as it allows us to properly separate compile-time and runtime code while maintaining the directory structure needed for dynamic imports.
