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'),
      },
    })
  },
})

In most cases, your handler files will differ only in their imports:

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

export default defineEventHandler(() => ({
  version: useRuntimeConfig().public.myModule.version,
}))

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/h3The 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).

Because this relies on @nuxt/kit utilities, make sure it is in your module's dependencies, which is what the module starter sets up. If you moved @nuxt/kit to peerDependencies, the application's version applies.

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'),
    })
  },
})
addServerImports, addServerImportsDir and addServerTemplate do not differ between Nitro 2 and 3. If you have code that differs, you can use getNitroVersion to conditionally register imports/templates differently.

Declaring What You Cannot Infer

Nuxt reads a registered file's imports to decide which API it uses, so you don't need to declare your compatibility. 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.

Nitro-specific Code

nuxt/server covers request and response work: handlers, errors, query and body reading, headers, cookies, redirects, route rules and runtime config. 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
Sessions: useSession, getSession, updateSession, clearSessionnitro/h3h3
Caching: defineCachedHandler(), defineCachedFunction()nitro/cachenitropack/runtime

Nitro's runtime hooks (useNitroApp().hooks, including render:html) and tasks are obviously also not supported by nuxt/server.

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. You can 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.