Creating an App Builder or Server Builder

Learn how Nuxt app builders and server builders differ, and how to author either one.

It's possible to create a custom app builder or a custom server builder:

  • An app builder bundles your application: the browser bundle and, when SSR is enabled, the SSR app entry. Nuxt ships three official app builders, Vite (the default), webpack and Rspack. You select one with the builder option, or supply your own. Throughout the rest of this guide, "builder" on its own means an app builder.
  • A server builder turns the app build into a deployable server: it initializes an SSR renderer, resolves the modules the renderer imports, and describes what it produced to deploy targets. @nuxt/nitro-server, backed by Nitro, is the default server builder, and @nuxt/vite-server is a second experimental one which ties into the Vite Environment API.

The two builders talk to each other through the build output contract: the app builder provides artifacts by key, and the server builder resolves them into modules the renderer can import. Neither one names the other, so in theory any app builder should work with any server builder.

This guide explains how each role fits into the Nuxt build, what contract each fulfills, and how to author one. It assumes you're familiar with the Nuxt interface and with writing modules.

Most apps never need a custom builder of either kind. If you only want to influence the bundle, a module that registers bundler plugins is usually the right tool.

What an App Builder Does

Nuxt separates what to build from how to build it.

Nuxt core and your modules generate the virtual application: the entrypoints, route table, plugins, component registry, and the rest of the virtual file system under #build. The builder turns that virtual application into real JavaScript and CSS bundles, and in development runs a dev server that serves and hot-reloads them.

An app builder is responsible for:

  • Bundling the client build (the browser bundle) and, when SSR is enabled, the server build (the SSR app entry).
  • Producing the artifacts the server runtime needs to render and hydrate, such as the client manifest and the per-component styles map (see The Build Output Contract).
  • Exposing a dev server in development, and triggering reloads when the build changes.

The deployable server itself is produced by the server builder, not by the app builder. With the default server builder, Nitro bundles the app builder's outputs into the final .output.

The App Builder Interface

An app builder is an object implementing the NuxtBuilder interface. The only required method is bundle:

import type { Nuxt } from '@nuxt/schema'

export interface NuxtBuilder {
  bundle: (nuxt: Nuxt) => Promise<void>
  /**
   * Optional. When the user opts in via `experimental.watcher: 'builder'`,
   * Nuxt calls this instead of starting its own dev file watcher, letting
   * the builder reuse its own watcher. The builder should register a
   * `nuxt.hook('close', ...)` to clean up.
   */
  setupWatcher?: (nuxt: Nuxt) => Promise<void> | void
}

The builder option accepts either a module specifier that default-exports a NuxtBuilder, or an inline object:

nuxt.config.ts
export default defineNuxtConfig({
  // a package that exports `{ bundle }`
  builder: '@nuxt/vite-builder',
})
nuxt.config.ts
import type { NuxtBuilder } from '@nuxt/schema'

const myBuilder: NuxtBuilder = {
  async bundle (nuxt) {
    // ...
  },
}

export default defineNuxtConfig({
  builder: myBuilder,
})

Nuxt calls bundle(nuxt) once during nuxt build and nuxt dev, after the virtual application has been generated and the build:before hook has fired. Nuxt wraps your bundle, so any error you throw triggers the build:error hook automatically.

Nuxt recognizes the three official app builder specifiers (@nuxt/vite-builder, @nuxt/webpack-builder, @nuxt/rspack-builder) by name for builder-specific behavior elsewhere in the build. A custom builder works through the generic contract described below, and is treated as the legacy (non-Vite-environment) path.

The App Build Lifecycle

When you run nuxt build or nuxt dev, Nuxt:

  1. Creates the nuxt context and runs modules, populating nuxt.options and the build hooks.
  2. Generates the virtual application (templates, route table, plugins) into the #build virtual file system.
  3. Fires build:before.
  4. Resolves the app builder and calls builder.bundle(nuxt). This is where your app builder runs.
  5. Fires build:done, and in production closes the nuxt instance.

Your bundle implementation typically will have a different pattern when run in development mode (nuxt.options.dev):

  • In production, it runs the client and (if nuxt.options.ssr) server builds to completion, writing artifacts to nuxt.options.buildDir and registering them as build outputs.
  • In development, it sets up a dev server, starts a watching build, assigns nuxt.server, and keeps running.

An app builder communicates with the rest of Nuxt almost entirely through hooks. It reads build configuration from nuxt.options and, where appropriate, lets modules extend its bundler configuration.

Letting Modules Extend the Bundler

Modules influence the bundle through Nuxt Kit helpers, and an app builder honors the relevant ones:

The official app builders also call their own hooks (for example vite:extendConfig, vite:serverCreated, and webpack:config) so modules and the server builder can participate in the build. Your app builder can call its own hooks too, but it's the build output contract below that makes it interoperate with the server builder and the Nuxt server runtime.

The Build Output Contract

The server runtime doesn't know which app builder produced the app. It renders against a fixed set of modules whose bodies only the build knows, and the active app builder provides each of them through nuxt.buildOutputs. This is the build output contract.

The contract has two sides:

  • The app builder provides the artifacts by key, as described in the rest of this section.
  • The server builder resolves them into modules the renderer can import, and asks Nuxt for the set rather than naming it (see Authoring a Server Builder).
Everything under nuxt/internal/* is part of the contract between Nuxt and a builder of either kind, not public API. Application code, and module code other than a builder's own implementation, must never import it. A builder that does import it should take the specifiers from the contract rather than hard-coding the subpaths, because they change without a major release.

The NuxtBuildOutputs interface in @nuxt/schema declares the contract:

export interface NuxtBuildOutputs {
  /** Module body re-exporting the SSR app entry. */
  serverEntry: () => string | Promise<string>
  /** Module body exporting the per-component SSR styles map, and the CSS inlined for each emitted file. */
  ssrStyles: () => string | Promise<string>
  /** Serialized client manifest for `vue-bundle-renderer`. */
  clientManifest: () => string | Promise<string>
  /** Serialized precomputed client dependency data for `vue-bundle-renderer`. */
  clientPrecomputed: () => string | Promise<string>
  /** Module body exporting the hashed entry chunk filename for import maps. */
  entryChunkName: () => string | Promise<string>
  /** Module body exporting the entry module IDs used for inline style extraction. */
  entryIds: () => string | Promise<string>
}

Every key is a function returning the module body as a string. Nuxt inlines the string verbatim into the server bundle, so it must not depend on the file's location on disk. serverEntry, for example, returns a body that re-exports the built SSR entry by absolute specifier:

setBuildOutput('serverEntry', () => `export { default } from ${JSON.stringify(serverEntryURL)}`)

As an app builder, provide only what your build produces. Every key has a default describing a build without the feature (an empty manifest, no inline styles, an undefined entry chunk), so the server runtime type-checks and builds even before an app builder runs. When SSR is disabled, the serverEntry default is a no-op app and most other outputs are unused.

Setting Build Outputs

Use the setBuildOutput helper from @nuxt/kit:

import { setBuildOutput } from '@nuxt/kit'

setBuildOutput('clientManifest', () => 'export default ' + serializedManifest)

setBuildOutput writes to nuxt.buildOutputs[key], resolving the nuxt instance through useNuxt(). Inside a bundler plugin that already holds the instance, you can assign nuxt.buildOutputs[key] directly.

A provider can be asynchronous, and Nuxt reads it lazily, when the server build resolves the module it backs. That lets your builder register a provider early, before the client build has finished, and return the finalized value once the data exists.

An app builder that runs inside Nitro's Vite environment (experimental.nitroViteEnvironment) has clientManifest, clientPrecomputed, entryIds and ssrStyles read after the server bundle is emitted, and spliced into it. Those bodies must take the form export default <expression>, optionally followed by export const <name> = <expression> lines; a re-export such as export { default } from '...' is rejected. An app builder that builds separately, as the example below does, isn't subject to this.

A Minimal Example

Here's the skeleton of an app builder that fulfills the contract for a production build:

import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { setBuildOutput } from '@nuxt/kit'
import type { NuxtBuilder } from '@nuxt/schema'

export const bundle: NuxtBuilder['bundle'] = async (nuxt) => {
  const serverDir = resolve(nuxt.options.buildDir, 'dist/server')

  // ...run your client and server bundles here, writing artifacts to disk...
  const { serializedClientManifest } = await runBundles(nuxt, serverDir)

  if (nuxt.options.ssr) {
    // Point `nuxt/internal/entry` at the built SSR app entry.
    const serverEntryURL = pathToFileURL(resolve(serverDir, 'server.mjs')).href
    setBuildOutput('serverEntry', () => `export { default } from ${JSON.stringify(serverEntryURL)}`)

    // Provide the client manifest produced by your client build.
    setBuildOutput('clientManifest', () => `export default ${serializedClientManifest}`)

    // If you emit a per-component styles map alongside its CSS chunks:
    setBuildOutput('ssrStyles', () => `export { default, inlinedCSS } from ${JSON.stringify(pathToFileURL(resolve(serverDir, 'styles.mjs')).href)}`)
  }
}

The App Builder's Development Server

In development, an app builder also serves and hot-reloads the app. Two things matter:

  • nuxt.server holds the running dev server. The Nuxt CLI consumes it, and the official app builders expose a handler (a Node request listener), a fetch (a web fetch handler), and reload and close methods on it. Whether you build your own dev server or delegate to Nitro's (createDevServer from nitro/builder) is up to you.
  • Reloads happen when you call nuxt.server.reload() as a compilation finishes. The official app builders emit a compiled hook (for example vite:compiled or webpack:compiled) that the server integration listens to in order to reload.

In development, build outputs are typically wired to live, in-memory sources rather than on-disk files. The SSR entry may be served from the bundler's in-memory output, for instance, and the client manifest computed from the dev module graph rather than read from nuxt.options.buildDir.

App Builders and the Vite Environment API

The contract above is deliberately builder-agnostic. It works for both the legacy path, where the app builder runs its own bundle and the server builder bundles the deployable separately through its own pass, and the newer Vite Environment API path, where Nitro runs as a Vite environment.

For a custom app builder, you only need to satisfy the generic contract. The Vite Environment API integration (experimental.nitroViteEnvironment) is specific to @nuxt/vite-builder; other app builders, including custom ones, use the legacy Nitro Rollup path.

The server builder decides which of the two paths is in use, rather than the flag doing so directly. A server builder declares whether its build is a pass of its own, run after the app build, or an environment of the app builder's build. @nuxt/nitro-server declares it from experimental.nitroViteEnvironment, and a server builder with no build pass of its own (such as @nuxt/vite-server) declares that Vite is building everything.

Authoring a Server Builder

The remaining sections cover the other role: taking the app build and standing up a server that renders with it. A server builder is not selected with the builder option; it's a module that registers the renderer's modules with its own bundler and describes the build it produced. If you're writing an app builder, you're done at this point.

Describing the Build to a Deploy Target

A server builder describes what it produced, so that tooling that deploys or previews the build doesn't have to know which builder ran. The description lives on nuxt.serverBuild, and you read it with useServerBuild() from @nuxt/kit/internal:

import { useServerBuild } from '@nuxt/kit/internal'

const build = useServerBuild()

build.output.root() // the project root: where a target's own config file lives
build.output.dir() // the build output directory
build.output.publicDir() // the deployable static assets within it

Every path is a function, because a server builder may only resolve it while initializing. Call them from a hook that runs after the server builder has run.

output.root() is deliberately not a bundler root. Nuxt points Vite's root at srcDir, so a deploy target that resolves its configuration file (or the paths written inside it) from the bundler's root looks inside your app directory rather than at the root of your project. A target that reads the description resolves both correctly.

nuxt.serverBuild and the setServerBuild() and useServerBuild() helpers are experimental, and are exported from @nuxt/kit/internal. Their shape will change without a major release while a second server builder is being built out.

Standing Up the SSR Renderer

The Nuxt SSR renderer isn't part of @nuxt/nitro-server. It ships in the nuxt package and is imported through builder-agnostic specifiers, so a server builder that isn't backed by Nitro can render with it. @nuxt/nitro-server is one consumer of it, and its own handler is a thin adapter.

You create the renderer with createNuxtRenderer(), which returns a web-standard handler. nuxt/internal/renderer is experimental, and both the specifier and the options it takes will change without a major release:

import { createNuxtRenderer } from 'nuxt/internal/renderer'

const renderer = createNuxtRenderer({
  runtimeConfig: () => useRuntimeConfig(),
  buildAssetsURL: (...path) => joinURL(baseURL, buildAssetsDir, ...path),
  publicAssetsURL: (...path) => joinURL(baseURL, ...path),
  getRouteRules: event => rulesFor(event.url.pathname),
  hooks: () => hooks,
  createResponse: (body, init) => new Response(body, init),
  createError: init => new HTTPError(init),
})

const response = await renderer.fetch(event)

A renderer owns everything it reads, so a bundle may create several. A module that renders something other than a route, such as an island handler, should render against the same loaded artifacts as the page renderer: create the state once with createRendererInstance() from nuxt/internal/renderer/instance, hand it to createNuxtRenderer(), and render from it directly elsewhere.

The event the renderer takes is described in web standards only (a Request, a URL, a response to influence, and a context), matching the RequestEvent shape in @nuxt/schema. Set ~app on it to name the event the application should see, and useRequestEvent() and the render hooks receive that instead. runtimeConfig is passed the event too, for a runtime that resolves it per request. Everything the renderer can't learn from the platform (route rules, hooks, response and error construction, early hints, island rendering, and the prerender caches) is passed in, so the renderer itself imports no server runtime.

What Nuxt Gives You

Some of the modules the renderer imports can only be written by an app builder: the client manifest, the SSR entry, the renderer's settings, and so on. You don't have to know what any of them are. getServerRuntime will return gives you the whole list, and you simply need to make each specifier resolve when imported:

import { SERVER_RUNTIME_VERSION, getServerRuntime } from '@nuxt/kit/internal'

const { version, modules, defines, entry } = getServerRuntime({ phase: 'server' })

if (version !== SERVER_RUNTIME_VERSION) {
  throw new Error(`[my-server] unsupported server runtime contract v${version}`)
}

for (const [specifier, module] of Object.entries(modules)) {
  // register each one however your bundler prefers: a virtual module, an alias, a file
  registerVirtualModule(specifier, () => module.code())
}

The call returns four things:

  • modules maps a module specifier to the code that must be behind it. Loop over it and register each one. Because you never write the specifiers yourself, Nuxt can add, remove, or rename modules and your builder will keep working.
  • defines are text replacements for import.meta.dev, import.meta.server, import.meta.client, and import.meta.prerender (and potentially other values). Apply them via a replacement transform. If you don't, unused branches stay in the bundle, and payload extraction and prerender caches will be disabled without any error.
  • entry is the specifier your server entry imports createNuxtRenderer from.
  • version verifies whether the version of Nuxt Kit your builder uses matches the one Nuxt is compatible with. Compare it with SERVER_RUNTIME_VERSION, and fail the build if they differ. That turns a breaking change into a clear error.

Read module.code() whenever your bundler needs the module body. Nuxt calls it lazily and may call it more than once, so you can register a module before its value exists and still get the final value. If you need a specific build artifact at a specific point in your own build, module.output tells you which build output key backs that module; it's absent for modules Nuxt generates itself.

Filling in What Only You Know

The renderer is compiled with a set of constants. Nuxt fills in every one it can work out from nuxt.options. The rest, anything that depends on route rules, on a page matcher, or on a file you emit, starts out set to "feature off". Pass overrides to replace those with your own values, written as JavaScript expressions:

getServerRuntime({
  overrides: async () => ({
    NUXT_RUNTIME_PAYLOAD_EXTRACTION: String(hasCachedRoutes),
    spaTemplate: JSON.stringify(await spaLoadingTemplate(nuxt)),
  }),
})

Pass overrides as a function when a value isn't final until later in the build. Nuxt calls it each time it reads the module body.

Finding the App Build's Output

If your server build bundles the files the app build wrote, rather than inlining module bodies, you need to know where those files are. Ask the build description:

import { useServerBuild } from '@nuxt/kit/internal'

const build = useServerBuild()

build.input.serverEntry() // the SSR entry the app build emitted
build.input.serverDir() // the directory its chunks landed in
build.input.clientDir() // the client build's assets
build.input.clientManifest() // the client manifest the renderer renders against
Import createNuxtRenderer from nuxt/internal/renderer in one module only: the one that is your server entry. Anywhere else, including modules your entry can reach such as an island handler or a route-rule helper, import from the narrower nuxt/internal/renderer/* paths. Importing the barrel from a shared module creates a cycle between chunks, and some bundlers turn that into an entry that exports nothing.

Providing the Server Imports

One of the modules in the list is nuxt/server, the set of server imports that app and module code uses on the server. The types always come from the nuxt package, but the implementation may come from whichever server builder is running.

By default, nuxt/server will resolve to the web-standard implementations Nuxt ships. They're written against the RequestEvent shape, and they're what @nuxt/vite-server runs on.

Whatever event you pass to a handler must match RequestEvent. Register your event's type in the ServerTypes registry in @nuxt/schema, which is what NuxtRequestEvent and useRequestEvent() resolve to. If your own event object isn't web-shaped, pass handlers a web-shaped view of it (toNuxtRequestEvent() returns one) and put your own event on the ~app property, so useRequestEvent() and the render hooks give app code your event instead.

If your runtime can implement some of these better than the shipped versions, point Nuxt at your own module:

import { setServerBuild } from '@nuxt/kit/internal'

setServerBuild({
  runtime: {
    runtimeConfig: 'my-server/runtime-config',
    server: 'my-server/runtime/nuxt-server',
  },
})

Your module must export every name that nuxt/server exports. Since the types come from the nuxt package either way, a missing export will fail at runtime. Re-export the parts you don't want to change from nuxt/internal/server-default. Inside your bundle, nuxt/server will resolve to your module.

runtime.runtimeConfig is the module that provides useRuntimeConfig. Nuxt reads it whether or not you replace anything else.

Giving Deploy Targets a Handler

Some deploy targets run the build themselves, in a worker or on a platform that hands them a Request. All they want is one module that exports fetch. If your build emits such a module, name it once the build that produced it has run:

setServerBuild({
  runtime: {
    runtimeConfig: 'my-server/runtime-config',
    handler: resolve(outputDir, 'server/index.mjs'),
  },
})

Leave runtime.handler unset if your output can't be imported as a module. @nuxt/nitro-server leaves it unset, because what a Nitro build exports depends on the preset it built for.

A target may also want the render inside its own source, as with a worker entry that answers some routes itself and renders the rest. @nuxt/vite-server handles this by resolving #server-entry to the render, as a module that the target's own bundler builds. The app is then compiled with that environment's export conditions, which is what lets it run on a worker runtime, and nothing has to hard-code a path inside the build directory:

worker/index.ts
import { fetch as render } from '#server-entry'

export default {
  fetch (request: Request) {
    return new URL(request.url).pathname === '/api/hello'
      ? Response.json({ message: 'hello from the worker' })
      : render(request)
  },
}
getServerRuntime(), serverBuild.input, serverBuild.runtime, the nuxt/internal/* modules, and the renderer helpers in @nuxt/kit/internal are experimental, and will change without a major release while a second server builder is being built out.

Next Steps

Learn more about the Nuxt interface and the build vs. runtime split.
Browse the Nuxt Kit builder utilities used to register bundler plugins and build outputs.