Migration 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 integration. The Nuxt-specific hooks and helpers of v3 (addCustomTab, extendServerRpc, startSubprocess, devtools:customTabs, …) are replaced by one entry point, 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 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), the devtools:ready hook never fires and your integration simply does not register; nothing breaks.

At a glance

v3v4Warning
addCustomTab() / devtools:customTabsctx.docks.register({ type: 'iframe', … })NDT_DEP_0005
refreshCustomTabs()ctx.docks.register(entry, true) or the update() handleNDT_DEP_0006
view: { type: 'launch' }ctx.docks.register({ type: 'launcher', … })NDT_DEP_0005
extendServerRpc()ctx.scope('my-module').rpc.register()NDT_DEP_0003
nuxt.devtools.rpc.broadcast / .functionsctx.scope('my-module').rpc.broadcast()NDT_DEP_0007
startSubprocess() / devtools:terminal:*ctx.terminals.startChildProcess()NDT_DEP_0004
startSubprocess().getProcess().getResult()NDT_DEP_0001
getServerData RPCData Inspector Nuxt Application sourceNDT_DEP_0009
client.devtools.extendClientRpc() (iframe)kit.scope('my-module').rpc.register()console warning
useDevtoolsClient() (iframe)getDevToolsRpcClient() from @vitejs/devtools-kit/client—
devtools.vscode optiondevtools.codeServerNDT_DEP_0008

The rest of this page walks through a migration in the order you will hit it. The repo's module-starter playground is the finished result.

Step 1: dependencies

package.json
{
  "dependencies": {
-   "@nuxt/devtools-kit": "^3.0.0"
+   "@nuxt/devtools-kit": "^4.0.0",
+   "@vitejs/devtools-kit": "^0.7.6"
  }
}

@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:

- 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):
    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):

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:

- 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 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:

- 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:

- 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:

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:

- 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 }:

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):

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():

- 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:

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, 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 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 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.
  • client.devtools.open() / close() / toggle() still exist and now drive the Vite DevTools panel.
  • 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.

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.