---
title: "Migrating Modules to v4"
description: "Move your module integration from the Nuxt DevTools v3 API to the Vite DevTools based v4 API."
canonical_url: "https://nuxt.com/modules/devtools/module-migration"
---
# Migrating Modules to v4

> Move your module integration from the Nuxt DevTools v3 API to the Vite DevTools based v4 API.

Nuxt DevTools v4 is a [Vite DevTools](https://github.com/vitejs/devtools) integration. The Nuxt-specific hooks and helpers of v3 (`addCustomTab`, `extendServerRpc`, `startSubprocess`, `devtools:customTabs`, …) are replaced by one entry point, [`onDevtoolsReady()`](https://nuxt.com/modules/devtools/utils-kit#ondevtoolsready), which hands you the Vite DevTools node context. From there you register **docks** (tabs), **RPC functions**, **terminals** and **messages** on the same hosts every other Vite tool uses.

If you only use Nuxt DevTools in your app, read [Upgrading to v4](https://nuxt.com/modules/devtools/upgrading-to-v4) instead.

## Should I migrate now?

Yes. DevTools v4 runs on Nuxt 4.5+ and Nuxt 5, so the new API covers every Nuxt version that can run v4.

The v3 API keeps working on v4 through compatibility shims, each of which prints a coded `NDT_DEP_xxxx` warning in the user's terminal with a link to the matching section below. Those shims stay for the whole v4 cycle and are removed in v5.

A migrated module requires DevTools v4. On a project still running DevTools v3 (Nuxt 4 without the [override](https://nuxt.com/modules/devtools/upgrading-to-v4#nuxt-45)), the `devtools:ready` hook never fires and your integration simply does not register; nothing breaks.

## At a glance

| v3                                           | v4                                                          | Warning                         |
| -------------------------------------------- | ----------------------------------------------------------- | ------------------------------- |
| `addCustomTab()` / `devtools:customTabs`     | `ctx.docks.register({ type: 'iframe', … })`                 | [`NDT_DEP_0005`](#ndt_dep_0005) |
| `refreshCustomTabs()`                        | `ctx.docks.register(entry, true)` or the `update()` handle  | [`NDT_DEP_0006`](#ndt_dep_0006) |
| `view: { type: 'launch' }`                   | `ctx.docks.register({ type: 'launcher', … })`               | [`NDT_DEP_0005`](#ndt_dep_0005) |
| `extendServerRpc()`                          | `ctx.scope('my-module').rpc.register()`                     | [`NDT_DEP_0003`](#ndt_dep_0003) |
| `nuxt.devtools.rpc.broadcast` / `.functions` | `ctx.scope('my-module').rpc.broadcast()`                    | [`NDT_DEP_0007`](#ndt_dep_0007) |
| `startSubprocess()` / `devtools:terminal:*`  | `ctx.terminals.startChildProcess()`                         | [`NDT_DEP_0004`](#ndt_dep_0004) |
| `startSubprocess().getProcess()`             | `.getResult()`                                              | [`NDT_DEP_0001`](#ndt_dep_0001) |
| `getServerData` RPC                          | Data Inspector `Nuxt Application` source                    | [`NDT_DEP_0009`](#ndt_dep_0009) |
| `client.devtools.extendClientRpc()` (iframe) | `kit.scope('my-module').rpc.register()`                     | console warning                 |
| `useDevtoolsClient()` (iframe)               | `getDevToolsRpcClient()` from `@vitejs/devtools-kit/client` | —                               |
| `devtools.vscode` option                     | `devtools.codeServer`                                       | [`NDT_DEP_0008`](#ndt_dep_0008) |

The rest of this page walks through a migration in the order you will hit it. The repo's [`module-starter` playground](https://github.com/nuxt/devtools/tree/main/playgrounds/module-starter) is the finished result.

## Step 1: dependencies

```diff [package.json]
{
  "dependencies": {
-   "@nuxt/devtools-kit": "^3.0.0"
+   "@nuxt/devtools-kit": "^4.0.0",
+   "@vitejs/devtools-kit": "^0.8.0"
  }
}
```

`@vitejs/devtools-kit` provides the browser-side RPC client for your iframe and the types for the node context. Keep `@nuxt/devtools` as a dev dependency for your playground.

## Step 2: register your tab as a dock entry {#ndt_dep_0005}

`addCustomTab()` and the `devtools:customTabs` hook are deprecated (`NDT_DEP_0005`). Register a dock entry from `onDevtoolsReady()` and put it in the `Nuxt` group so it shows up next to the built-in tabs:

```diff
- import { addCustomTab } from '@nuxt/devtools-kit'
+ import { NUXT_DEVTOOLS_GROUP_ID, onDevtoolsReady } from '@nuxt/devtools-kit'

- addCustomTab({
-   name: 'my-module',
-   title: 'My Module',
-   icon: 'carbon:apps',
-   view: { type: 'iframe', src: '/__my-module' },
- })
+ onDevtoolsReady((ctx) => {
+   ctx.docks.register({
+     id: 'my-module',
+     title: 'My Module',
+     icon: 'carbon:apps',
+     type: 'iframe',
+     url: '/__my-module',
+     groupId: NUXT_DEVTOOLS_GROUP_ID,
+   })
+ })
```

Field mapping: `name` → `id`, `view.src` → `url`, `view.type` → `type`. Icons are Iconify names or image URLs, as before.

- **Categories.** Set `category` on the entry (`app`, `analyze`, `server`, `modules`, `documentation`, `advanced`) to pick its section inside the `Nuxt` group. Legacy custom tabs are listed under `modules`; set `category: 'modules'` to sit beside them.
- **Lazy launch.** Replace `view: { type: 'launch' }` with a native `launcher` entry, then re-register the same `id` as an `iframe` once the service is up (the second argument forces the replacement):
  ```ts
  onDevtoolsReady((ctx) => {
    ctx.docks.register({
      id: 'my-module',
      title: 'My Module',
      icon: 'carbon:apps',
      type: 'launcher',
      groupId: NUXT_DEVTOOLS_GROUP_ID,
      launcher: {
        title: 'Start My Module',
        description: 'Starts the inspector server on demand.',
        async onLaunch() {
          await startInspector()
          ctx.docks.register({
            id: 'my-module',
            title: 'My Module',
            icon: 'carbon:apps',
            type: 'iframe',
            url: '/__my-module',
            groupId: NUXT_DEVTOOLS_GROUP_ID,
          }, true)
        },
      },
    })
  })
  ```
- **Leaving the group.** Omit `groupId` to register a top-level dock next to Vue DevTools and Vite Inspect instead of inside `Nuxt`.
- **`vnode` views** have no native equivalent: a dock renders in its own iframe, so it cannot receive a Vue vnode from the Nuxt client. Move the UI into an iframe page, or keep `addCustomTab()` for that tab until you do.

### `refreshCustomTabs()` {#ndt_dep_0006}

Deprecated (`NDT_DEP_0006`). There is no tab list to re-collect; change the entry itself. `register()` returns a handle for patching the fields of an entry, and `register(entry, true)` replaces it wholesale (for example to change its `type`, as in the launcher example above):

```ts
onDevtoolsReady((ctx) => {
  const entry = ctx.docks.register({ id: 'my-module', /* … */ })
  entry.update({ badge: '3' })
})
```

## Step 3: server RPC {#ndt_dep_0003}

`extendServerRpc()` is deprecated (`NDT_DEP_0003`). Register functions on the Vite DevTools RPC host. Use `ctx.scope()` so every name is prefixed with your namespace without you repeating it:

```diff
- import { extendServerRpc, onDevToolsInitialized } from '@nuxt/devtools-kit'
+ import { onDevtoolsReady } from '@nuxt/devtools-kit'

- onDevToolsInitialized(() => {
-   const rpc = extendServerRpc('my-module', {
-     toUpperCase(t: string) {
-       rpc.broadcast.greeting('world')
-       return t.toUpperCase()
-     },
-   })
- })
+ onDevtoolsReady((ctx) => {
+   const { rpc } = ctx.scope('my-module')
+
+   rpc.register({
+     name: 'to-upper-case', // registered as `my-module:to-upper-case`
+     type: 'query',
+     handler(t: string) {
+       rpc.broadcast({ method: 'greeting', args: ['world'], event: true })
+       return t.toUpperCase()
+     },
+   })
+ })
```

`type` is `'query'` for reads, `'action'` for mutations, `'event'` for fire-and-forget and `'static'` for values that never change. Pass `args` / `returns` [Standard Schema](https://standardschema.dev/) validators to type and validate a function at the boundary.

### `nuxt.devtools.rpc` direct access {#ndt_dep_0007}

Reading `nuxt.devtools.rpc.broadcast` or writing to `nuxt.devtools.rpc.functions` is deprecated (`NDT_DEP_0007`). Both map onto the scoped RPC host above:

```diff
- nuxt.devtools.rpc.broadcast.myEvent.asEvent(payload)
+ onDevtoolsReady((ctx) => {
+   ctx.scope('my-module').rpc.broadcast({ method: 'my-event', args: [payload], event: true })
+ })

- nuxt.devtools.rpc.functions.myFn = handler
+ onDevtoolsReady((ctx) => {
+   ctx.scope('my-module').rpc.register({ name: 'my-fn', handler })
+ })
```

## Step 4: the iframe client

A native dock is rendered by Vite DevTools, not by the Nuxt client, so the `__NUXT_DEVTOOLS__` object that `useDevtoolsClient()` and `onDevtoolsClientConnected()` rely on is **not injected** into it. Connect to Vite DevTools directly instead:

```diff
- import { onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
+ import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client'

- onDevtoolsClientConnected(async (client) => {
-   const rpc = client.devtools.extendClientRpc('my-module', {
-     greeting(t: string) {
-       console.log(`Hello ${t}`)
-     },
-   })
-   const result = await rpc.toUpperCase('hello')
- })
+ const kit = await getDevToolsRpcClient()
+ const { rpc } = kit.scope('my-module')
+
+ rpc.register({
+   name: 'greeting', // receives the `my-module:greeting` broadcast
+   type: 'event',
+   handler(t: string) {
+     console.log(`Hello ${t}`)
+   },
+ })
+ const result = await rpc.call('to-upper-case', 'hello')
```

The user's app is the parent window of every dock iframe, so the host client that v3 exposed as `client.host` is still reachable on it from a same-origin iframe:

```ts
import type { NuxtDevtoolsHostClient } from '@nuxt/devtools-kit/types'

const host = (window.parent as Window & { __NUXT_DEVTOOLS_HOST__?: NuxtDevtoolsHostClient }).__NUXT_DEVTOOLS_HOST__
host?.nuxt.vueApp.version
host?.devtools.close()
```

`host` is `undefined` when your page is opened outside DevTools or served from another origin.

`client.devtools.extendClientRpc()` is deprecated and logs a console warning; `useDevtoolsClient()` still works for tabs that are still registered through `addCustomTab()`.

## Step 5: terminals {#ndt_dep_0004}

`startSubprocess()` is deprecated (`NDT_DEP_0004`). Spawn processes through the Vite DevTools terminals host; they show up in the shared **Terminals** dock with restart and terminate controls:

```diff
- import { startSubprocess } from '@nuxt/devtools-kit'
+ import { onDevtoolsReady } from '@nuxt/devtools-kit'

- const subprocess = startSubprocess(
-   { command: 'vite', args: ['build', '--watch'] },
-   { id: 'my-module:build', name: 'Build', icon: 'ph:terminal-duotone' },
- )
+ onDevtoolsReady(async (ctx) => {
+   const session = await ctx.terminals.startChildProcess(
+     { command: 'vite', args: ['build', '--watch'], cwd: process.cwd() },
+     { id: 'my-module:build', title: 'Build', icon: 'ph:terminal-duotone' },
+   )
+ })
```

The session exposes `terminate()`, `restart()`, `getChildProcess()` and `getResult()`, an awaitable `{ stdout, stderr, exitCode }`:

```ts
const { exitCode, stderr } = await session.getResult()
if (exitCode !== 0)
  console.error(stderr)
```

`ctx.terminals.startPtySession()` gives you an interactive PTY, and `ctx.terminals.register({ id, title, status, stream })` surfaces output from a process you keep owning yourself.

### Legacy `devtools:terminal:*` hooks

Calling `devtools:terminal:register` / `:write` / `:exit` / `:remove` directly (which is what `startSubprocess()` does internally) is bridged onto the Terminals dock **for output and final status only**:

- `restartable` / `terminatable` and `onActionRestart` / `onActionTerminate` are ignored: no buttons are shown for a bridged session;
- re-registering an id (`clear()` / `restart()`) shows up as a new session rather than resetting the old one in place;
- `terminate()` / `restart()` on a `startSubprocess()` handle still work, because that helper owns its process.

### `SubprocessOptions` no longer extends execa

`startSubprocess()` moved from `execa` to `tinyexec`. `command`, `args`, `cwd` and `env` are unchanged; every other execa option goes under `nodeOptions` (Node's `SpawnOptions`):

```diff
startSubprocess({
  command: 'my-command',
  cwd: '/some/path',
- stdio: 'pipe',
+ nodeOptions: { stdio: 'pipe' },
})
```

### `getProcess()` {#ndt_dep_0001}

Deprecated (`NDT_DEP_0001`); it returns a plain `ChildProcess | undefined` now. `getResult()` returns the tinyexec result with `.process`, `.kill()` and `.pipe()`:

```diff
- const proc = subprocess.getProcess()
- proc.stdout.on('data', handler)
+ const result = subprocess.getResult()
+ result.process?.stdout?.on('data', handler)
```

## Step 6: notifications

`ctx.messages` is the Vite DevTools messages host. `ctx.messages.add({ message, level, notify })` adds an entry to the **Messages** dock; `notify: true` also shows it as a toast:

```ts
onDevtoolsReady((ctx) => {
  ctx.messages.add({ message: 'My module is ready', level: 'info', notify: true })
})
```

The `devtools:notify` Nuxt hook forwards to the same host and is not deprecated.

## Other changes

### `getServerData` RPC {#ndt_dep_0009}

The read-only Nuxt Options Viewer is replaced by the [Data Inspector](https://nuxt.com/modules/devtools/features#data-inspector), whose `Nuxt Application` source exposes the same data through a live jora workbench. The `getServerData` RPC that fed the old page still answers with the legacy `{ nuxt, nitro, vite: { server, client } }` shape but prints `NDT_DEP_0009` on first use.

To expose your own data, install [`@devframes/plugin-data-inspector`](https://npmx.dev/package/@devframes/plugin-data-inspector) and register a source with its `registerDataSource` API.

### `vscode` option {#ndt_dep_0008}

The built-in VS Code integration is replaced by the Code Server plugin (`devtools.codeServer`). Supplying `devtools.vscode` prints `NDT_DEP_0008` and the value is ignored. See [Upgrading to v4](https://nuxt.com/modules/devtools/upgrading-to-v4#code-server) for the user-facing details.

### Removed APIs

- `@nuxt/devtools-wizard`, `nuxi devtools enable/disable` and the `runWizard` RPC. The one action that remains, `enablePages`, is a direct RPC function with no arguments: `rpc.enablePages()`. The `WizardFunctions`, `WizardActions` and `GetWizardArgs` types are gone.
- `client.devtools.popup` (Picture-in-Picture) and the `showPanel` / `minimizePanelInactive` UI settings; Vite DevTools owns the panel.
- Global installs: `devtoolsGlobal`, the `-g` install flag and `NuxtDevtoolsInfo.isGlobalInstall`.
- The `viteDevTools` module option; Vite DevTools is always on. On Vite 8.3+ Nuxt DevTools configures Vite's top-level `devtools` option, so `vite: { devtools: false }` turns it off.
- The static assets RPCs `getStaticAssets`, `getImageMeta`, `getTextAssetContent`, `writeStaticAssets`, `deleteStaticAsset` and `renameStaticAsset`, together with the `AssetInfo`, `AssetEntry`, `AssetType` and `ImageMeta` types. The Assets tab is now [`@devframes/plugin-assets`](https://npmx.dev/package/@devframes/plugin-assets).

### Changed behavior

- `client.devtools.open()` / `close()` / `toggle()` still exist and now drive the Vite DevTools panel.

### Peer dependencies

`@nuxt/devtools` requires Vite `^8.1.5` (was `>=6.0`) and `@nuxt/kit` 4 or 5. Nitro v2 (`nitropack`, Nuxt 4) and Nitro v3 (`nitro`, Nuxt 5) are both optional peers; the one your user's Nuxt installs is the one that gets used. `unstorage` is an optional peer for the same reason: the Storage tab uses the copy that Nitro brings.


## Sitemap

See the full [sitemap](https://nuxt.com/sitemap.md) for all pages.
