Server Compatibility

Ship one module that serves Nuxt 4 and Nuxt 5, whichever server runtime is underneath.

Nuxt supports multiple server runtimes. Nuxt 3 and 4 target Nitro v2 (nitropack) which uses h3 v1; Nuxt 5 defaults to Nitro v3 (nitro) which uses h3 v2. And it's also possible for users to use a custom server runtime based on Web APIs which is compatible everywhere (nuxt/server).

To assist in the upgrade from Nuxt 4, module code written for Nitro v2 can keep running through a temporary compatibility layer. This guide covers what the layer does for you and how to migrate to full support for Nuxt 4 and 5

If your module has no server code, nothing here applies.

The Compatibility Layer

A module's server code enters the compatibility layer when its files import from h3, nitropack, nitropack/runtime, #internal/nitro or #imports. (A file that imports nuxt/server or nitro/* is left alone.)

Inside this compatibility layer, h3 resolves to an h3 v1 helper surface built on h3 v2, the nitropack specifiers resolve to their Nitro v3 equivalents, useRuntimeConfig(event) keeps its per-request behavior, and we inject the event.node, event.context.nitro and event.context._nitro.routeRules shapes that v1 code might read.

To help with migration, Nuxt will log which modules it applied the layer to:

WARN [NUXT_B9003] Nitro v2 compatibility was applied to server code from 2 modules, because of what it imports:
  - @nuxtjs/robots (imports `h3`, `nitropack/runtime`, `#imports`)
  - nuxt-site-config (imports `h3`, `nitropack/runtime`)
The layer covers the specifiers your own code imports. It does not supply packages that nitropack v2 happened to hoist into the server bundle, like lru-cache or similar. Always declare your module's dependencies in package.json.

nuxt/server is the import surface for writing agnostic server code based on Web APIs, and it ships in Nuxt 4.6+. Code written with it runs on nitropack v2, on Nitro v3, and under other server builders, such as @nuxt/vite-server.

Event Handlers

If you are registering event handlers, keep your current handler (for compatibility with Nuxt <4.6) and add a new portable one using nuxt/server beside it, and register both. Nuxt will register the implementation the application can run: the portable file under Nitro v3 and under other builders, the v2 file on a host still running nitropack v2.

module.ts
import { addServerHandler, createResolver, defineNuxtModule } from '@nuxt/kit'

export default defineNuxtModule({
  meta: { name: 'my-module' },
  setup () {
    const { resolve } = createResolver(import.meta.url)

    addServerHandler({
      route: '/api/my-module/status',
      handler: {
        nuxt: resolve('./runtime/server/status'),
        nitro2: resolve('./runtime/server/status.legacy'),
      },
    })
  },
})

The two files differ in their imports, and wherever the handler uses an h3 v1 helper or behavior that nuxt/server handles differently:

import { defineEventHandler, useRuntimeConfig } from 'nuxt/server'

export default defineEventHandler(() => ({
  version: useRuntimeConfig().public.myModule.version,
}))
See every difference between h3 v1 and nuxt/server, and what to use instead.

The keys indicate which server API each file relies upon:

KeyThe file importsWhere it runs
nuxtnuxt/server onlyAny server builder, from Nuxt 4.6
nitro2h3, nitropack/runtime, #importsnitropack v2 directly, Nitro v3 through the compatibility layer
nitro3nitro, nitro/h3, alone or alongside nuxt/serverThe Nitro server builder, v3

Nuxt will pick the most appropriate handler to use based on the server builder a user is using. If you register a handler which the user's server builder cannot run, it will be skipped with a warning.

Use nitro3 only for code that needs an API only Nitro v3 offers. Using nuxt/server will keep your code more portable in future.

Using this pattern means you will support both old (<4.6) and new versions of Nuxt (including Nuxt 5).

The object form of handler needs @nuxt/kit@^4.6 in your module's dependencies, which is what the module starter sets up. If @nuxt/kit is only a devDependency and left external when you build, or is a peerDependency, the application's version runs instead, and a version older than 4.6 fails on the object handler.

To typecheck the nuxt files, install nuxt@>=4.6 as a devDependency. To typecheck nitro3 files, also install nitro.

If your module's own aliases already resolve to the right code on both Nitro majors, register the same file for both keys ({ nitro2: file, nitro3: file }) or set meta.compatibility.server.

On a nitropack v2 host, Nuxt prefers the nitro2 variant, so the nuxt variant only runs there if it is the only one registered. Test the nuxt variant on Nuxt 5.

In playgrounds and test fixtures on Nuxt 5, h3's auto-imported defineEventHandler is not available: import it from nuxt/server.

Server Utilities

If your module exposes utilities that take the event (through an alias, addServerImports or a type template), type the parameter as RequestEvent from nuxt/server, or as the part it reads, such as Pick<RequestEvent, 'req' | 'context'>. A parameter typed as H3Event only accepts the event of one h3 major, so the utility can't be called from a nuxt/server handler.

getNitroVersion reports the Nitro major, not which server API the host runs: a host with a different server.builder runs none of the Nitro ones.

Server Plugins

Server plugins take variants too, although they are specific to Nitro so there is no generic nuxt variant to register.

Because defineServerPlugin is an identity function, you may be able to replace it with a type annotation to write code that's portable for both nitro2 and nitro3.
module.ts
import { addNitroPlugin, createResolver, defineNuxtModule } from '@nuxt/kit'

export default defineNuxtModule({
  meta: { name: 'my-module' },
  setup () {
    const { resolve } = createResolver(import.meta.url)

    addNitroPlugin({
      nitro3: resolve('./runtime/server/plugin'),
      nitro2: resolve('./runtime/server/plugin.legacy'),
    })
  },
})

Other Variants

For anything else that points at server code, such as an alias, a type template or an app plugin that reads the server event, you can use resolveServerVariant to resolve the variant that is appropriate for this version of Nuxt, or undefined if it runs none of them.

addServerImports accepts the same variants in from.

module.ts
import { addServerImports, createResolver, defineNuxtModule, resolveServerVariant } from '@nuxt/kit'

export default defineNuxtModule({
  meta: { name: 'my-module' },
  setup (_options, nuxt) {
    const { resolve } = createResolver(import.meta.url)

    const someServerFile = resolveServerVariant({
      nuxt: resolve('./runtime/server'),
      nitro2: resolve('./runtime/server.legacy'),
    })

    if (someServerFile) {
      nuxt.options.alias['#my-module/server'] = someServerFile
    }

    addServerImports({
      name: 'verifyToken',
      from: { nuxt: resolve('./runtime/verify'), nitro2: resolve('./runtime/verify.legacy') },
    })
  },
})

If running with nitropack v2, nitro2 will always be preferred over nuxt, and if running with nitro v3, nitro3 will always be preferred over nuxt. So, if you need to test the nuxt variant, set server.builder in your test fixture to a builder that is not Nitro, such as @nuxt/vite-server.

Declaring What You Cannot Infer

Nuxt reads the imports of each registered file, and of the files it imports, to decide which API it uses, so you don't need to declare your compatibility. Type-only imports (import type) are ignored, as are files in your runtime directory that no registered file imports. But if this isn't sufficient, you can set meta.compatibility.server:

module.ts
export default defineNuxtModule({
  meta: {
    name: 'my-module',
    compatibility: { server: 'nuxt' },
  },
})

If we can't tell, code will be treated as nitro2.

Nuxt reads the imports of your module's own files, not of the packages they call. A nuxt file that calls a library which imports Nitro internally is accepted, but fails under a builder other than Nitro.

Typing Runtime Config and Events

To type runtime config keys your module adds, augment RuntimeConfig (or PublicRuntimeConfig) from nuxt/schema. The keys are then typed in nuxt, nitro2 and nitro3 files alike.

module.ts
declare module 'nuxt/schema' {
  interface RuntimeConfig {
    myModule: { apiKey: string }
  }
}

Builder-specific types, such as H3Event for useRequestEvent() or the full Nitro config in the nitro:config hook, come from the generated .nuxt/nuxt.d.ts. Without it, they resolve to builder-agnostic types (RequestEvent and a reduced Nitro config). Type utilities that accept an event against RequestEvent to support any server builder.

Plugins, Storage and Caching

A module built on server plugins, storage or caching has no nuxt variant, because nuxt/server doesn't cover those areas. Register its files as nitro2 now, and add a nitro3 variant when you port them.

Nitro-specific Code

nuxt/server covers request and response work: handlers, errors, router params, query and body reading and validation, headers, CORS, cookies, sessions, redirects, route rules, runtime config, app config, in-process fetch with serverFetch() and runtime hooks with useServerHooks(). But Nitro covers a lot more, so if you need any of these areas, you might need to stay on Nitro's own imports inside a nitro2 or nitro3 file.

Use the specifiers of the version the file is for. A file that imports nitro/* is read as Nitro 3 code, so a nitro2 file must import h3 and nitropack/* instead:

AreaIn a nitro3 fileIn a nitro2 file
Storage: useStorage() and its driver configurationnitro/storagenitropack/runtime
Caching: defineCachedHandler(), defineCachedFunction()nitro/cachenitropack/runtime
Server pluginsdefinePlugin() from nitrodefineNitroPlugin() from nitropack/runtime
Nitro's own runtime hooks, such as request, response and erroruseNitroHooks() from nitro/appuseNitroApp().hooks from nitropack/runtime
Lazy handlers: lazyEventHandler()nitro/h3h3
Tasks: defineTask(), runTask()nitro/tasknitropack/runtime
See which h3 helpers work with the portable event, and which need Nitro-specific utilities.

Notable Changes in Nitro

These behaviors changed with Nitro v3 and may require further updates in your code:

  • Routed middleware no longer runs for subpaths. nitropack v2 mounted middleware with a route so that it ran for that route and everything below it (such as /path/subpath), with the route stripped from event.path. Nitro v3 matches it exactly, like any other handler. When updating, widen the route to /path/** if you mean to cover both, and read the full path from event.path.
  • A handler registered with no route was global middleware. Nitro v3 requires a route. If you need middleware, register it with middleware: true and an explicit route, even if it is intended to run on every path /**.
  • beforeResponse and afterResponse are not Nitro v3 hooks. They will work in the compatibility layer, but you should move to the response hook, which receives the built Response. Note that (even with the compatibility layer) a hook that replaces response.body, or reads it while streaming, is not supported.
  • globalThis.$fetch is not present in Nitro 3. For a route of the app, use serverFetch(event, path) from nuxt/server. You can also use event.$fetch or import $fetch from #imports/server.
  • import.meta.url is no longer the server entry. Nitro v2 rewrote it to a global pointing at the entry, but Nitro v3 emits the real value. Resolve assets from a path you control instead.