Nuxt CLI v4 Is Great for AI Coding Agents
Have you ever had a dev server running, and then asked your coding agent to test something in that app? It starts its own server on port 3001. Then it restarts that one, and you end up with another on 3002. Pretty soon you've lost track of what's running on which port and what you need to stop...
Nuxt CLI v4 shipped alongside Nuxt 4.6 in early October, and a few of its new features fix that exact problem. The Nuxt team pitched this release as a speed and dev experience update. Nuxt's own numbers put the CLI install at 3.5 MB (down from 13.1 MB) and first paint for nuxt dev at 50 ms (down from 330 ms). However, what I'm really interested in is some of the agentic features.
I tried them on a small Nuxt 4.6.4 todo app. Here's the video if you'd rather watch:
{% embed youtube.com %}
Prerequisites
- Node.js 22.21+, 24.11+, or 26+. CLI v4 won't run on older versions.
- A Nuxt project on 4.6 or later. CLI v4 is a dependency of nuxt, so upgrading Nuxt gets you the new CLI. I tested nuxt 4.6.4 with @nuxt/cli 4.0.3.
- Optional, a coding agent that can run shell commands.
1. Meet the new nuxt dev
Run npm run dev and the first thing you'll notice is the panel pinned to the bottom of your terminal. It shows your Nuxt version, the local URL, a request counter, and a row of single-key shortcuts.
!The new nuxt dev terminal UI showing Nuxt 4.6.4, the local URL, 36 requests with an 11ms median, a READY badge, and shortcuts for restart, open, info, logs, network, routes, help, and quit
The new nuxt dev panel. Each letter at the bottom is a shortcut.
r restarts the server, o opens it in the browser, i shows info about your setup, and n lists every request with its status and timing. Press p for all your pages and server routes. If you'd rather have plain log output, nuxt dev --no-tui turns the panel off.
2. Check the lock file
When nuxt dev starts, it writes .nuxt/nuxt.lock. When you stop the server, the file goes away. Here's mine:
!The .nuxt/nuxt.lock file open in the editor with pid, startedAt, interactive, command, cwd, parentPid, port 8231, hostname 127.0.0.1, and url fields
.nuxt/nuxt.lock has everything an agent needs to find the running server.
The PID, port, and URL tell an agent that a server is already up and where to find it. It doesn't have to probe ports or guess at localhost:3000.
The interactive field records whether the server was started from an interactive terminal. Nuxt uses it to decide what happens when a second nuxt dev shows up. In my testing, a server started from a real terminal wouldn't hand itself over to a non-interactive nuxt dev (the kind an agent runs). The agent gets a message pointing it at --takeover instead. A server that was started non-interactively gets taken over on the same port, so an agent that restarts its own server doesn't leave the old one running.
3. Run nuxt dev a second time
If you run npm run dev again while a server is up, Nuxt stops and asks what you want:
!Terminal output reading "A Nuxt dev server is already running here (PID 107817, http://127.0.0.1:8231)" followed by three options, Take over port 8231, Do not start, and Start anyway
Taking over stops the running server. Start anyway gives you a second one.
Take over stops the running server and starts yours on the same port. That's the --takeover flag. Start anyway runs a second server, which you can also force with NUXT_IGNORE_LOCK=1. Nuxt calls that unsupported.
The CLI already warns an agent about all of this, but I still wanted the rules written down. So I added a steering file at .kiro/steering/nuxt-dev-server.md. If you use a different agent, put the same thing in AGENTS.md.
---
inclusion: always
---
# Nuxt dev server rules
This project uses Nuxt 4.6 with Nuxt CLI v4.
- Never start a second `nuxt dev`. A dev server is usually already running. Check `.nuxt/nuxt.lock` for its URL.
- Never pass `--takeover` or set `NUXT_IGNORE_LOCK=1` unless I ask.
- To test API routes, use `npx nuxt curl <path>` (add `-X`, `-d`, `-i` as needed). It finds the running server without a port.
- To run Nitro tasks, use `npx nuxt task list` and `npx nuxt task run <name> --payload.<key>=<value>`.
- Before using a Nuxt API you are unsure about, run `npx nuxt docs "<query>"` to read the docs for the installed version.
4. Test routes with nuxt curl
If the agent can't start its own server, it still needs a way to test things. That's what nuxt curl is for.
npx nuxt curl /api/todos
!Terminal running npx nuxt curl /api/todos and printing a formatted JSON array with two todos, Upgrade to Nuxt 4.6 and Let Kiro find my dev server
No host, no port. nuxt curl reads the lock file.
You pass a path, not a URL. nuxt curl reads the lock file, finds the running server, and pretty-prints the JSON it gets back. It takes the usual curl flags too, so a POST looks like this:
npx nuxt curl /api/todos -X POST -d '{"title":"Added by nuxt curl"}'
Add -i to see the response headers. If no server is running, it tells you to start one with nuxt dev or pass an absolute URL.
5. Run Nitro tasks with nuxt task
The other command I liked is nuxt task, which runs Nitro tasks against the running server. Tasks are still experimental in Nitro, so turn them on in nuxt.config.ts first:
export default defineNuxtConfig({
nitro: {
experimental: { tasks: true },
},
})
My app has a seed task in server/tasks/db/seed.ts that resets the in-memory todos:
export default defineTask({
meta: {
name: 'db:seed',
description: 'Replace the in-memory todos with N seeded todos',
},
run({ payload }) {
const count = Math.min(Math.max(Number(payload?.count) || 5, 1), 100)
const todos = resetTodos(count)
return { result: `Seeded ${todos.length} todos` }
},
})
Then list and run it from the terminal:
npx nuxt task list
npx nuxt task run db:seed
!Terminal showing npx nuxt task list returning db:seed, a failed npx nuxt task db:seed with an Unknown command error and usage help, then npx nuxt task run db:seed printing Seeded 5 todos
I forgot the run subcommand the first time. The help output got me there.
You can see I typed npx nuxt task db:seed first and got an error. It needs run. Pass a payload with --payload.count=10 and you get 10 todos instead of the default 5.
6. Hand errors to your agent
I really like this the most, because I run into this all the time! In my example my app has a /profile page that's broken on purpose:
<script setup lang="ts">
const user = { name: 'Erik', profile: null as null | { handle: string } }
const handle = user.profile!.handle.toUpperCase()
</script>
Load it and Nuxt shows the new error overlay. You get the error, the exact line in profile.vue, a toggle between source and compiled code, and a call stack with the 9 framework frames hidden by default.
!The Nuxt error overlay showing TypeError Cannot read properties of null (reading 'handle') at app/pages/profile.vue:5:30, with the Copy error menu open listing Copy as Markdown, Copy prompt for an agent, and Copy structured JSON
The Copy error menu has an option written for agents.
The new part is the Copy error menu. Copy as Markdown and Copy structured JSON do what they sound like. Copy prompt for an agent wraps the error, source location, Node version, and runtime details in instructions to diagnose the error, trace the root cause, and explain the failure.
I pasted that prompt into Kiro, running Claude Opus 5.5, and let it go. Because of the steering file, it didn't start a server. It ran npx nuxt curl /profile to confirm the 500, changed the non-null assertion to optional chaining and added a v-if so the template skips the missing handle, then ran nuxt curl again to confirm a 200.
!Kiro chat panel showing the verification steps. Before, npx nuxt curl /profile returned 500 with the profile.vue:5:30 stack trace. After, it returns 200 and renders the section. The file has no type errors
Kiro checked its own fix with nuxt curl against my running server.
The whole thing took about 36 seconds and one credit.
Wrap up
I'm really liking this new version of Nuxt CLI! I'd like to see more frameworks do this. Have you had an agent take over your dev server, or start five of them? Let me know in the comments.
Links
- Nuxt 4.6 release post
- Nuxt CLI v4.0.0 release notes
- Nitro tasks docs
- Kiro steering docs
- Kiro
- The video on YouTube
10 reactions · 1 comment
Nuxt SSR Streaming: Faster TTFB and the Response You Can't Change Later
Classic server rendering in Nuxt is all or nothing. The server runs every plugin, resolves every awaited useFetch, renders the whole Vue tree to a string, and only then sends a single byte back. If one API call on the page takes 600ms, the browser sits staring at a blank tab for 600ms, unable to even start downloading your CSS. Streaming SSR has been one of the longest-standing open issues in the Nuxt repo, and as of Nuxt 4.5 it finally ships behind a flag.
The win is real, but it comes with a contract change that is easy to trip over. Once the first byte is on the wire, the HTTP status and headers are locked in. Let's look at both halves.
Turning it on
Streaming is experimental and off by default. Enable it in nuxt.config.ts:
export default defineNuxtConfig({
experimental: {
ssrStreaming: true,
},
});
With this on, Nuxt immediately flushes an HTML shell (your <head>, styles, preload hints, and entry scripts) and then streams the body as Vue renders it. The browser can start fetching CSS, fonts, and the JS entry chunk while your server is still waiting on that slow API.
Bots and crawlers are excluded by default, so Googlebot and friends still get a fully buffered response. If you have an internal crawler or a link-preview service that chokes on streamed HTML, you can widen the match:
export default defineNuxtConfig({
experimental: {
ssrStreaming: {
botRegex: /googlebot|bingbot|slackbot|my-internal-crawler/i,
},
},
});
Be realistic about what improves. Time to First Byte drops reliably, because the shell no longer waits on your data. Largest Contentful Paint _may_ improve, depending on how much of the route's JavaScript overlaps the entry chunk and whether your LCP element sits behind the slow fetch. A page whose hero is rendered from an awaited API call still has to wait for that call before the hero arrives; you just stop wasting that time on idle network.
The rule: the response is committed at byte one
HTTP sends the status line and headers before the body. A buffered renderer can change its mind right up until the end. A streaming renderer cannot. Anything that tries to mutate the response _after_ rendering has begun simply can't reach the client.
That covers more code than you might expect:
<!-- pages/products/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
const { data: product } = await useFetch(`/api/products/${route.params.slug}`);
if (!product.value) {
// Too late under streaming: a 200 has already been sent
setResponseStatus(useRequestEvent()!, 404);
}
// Also too late: the Set-Cookie header is already gone
const lastViewed = useCookie("last-viewed");
lastViewed.value = String(route.params.slug);
</script>
The release notes call out setResponseStatus() in <script setup> and cookie writes during middleware as typical cases. Nuxt does not fail silently here: in development it logs a warning naming each dropped mutation. Treat those warnings as bugs, not noise.
Nuxt also handles the common cases for you. Routes with redirect, cache, isr, swr, noScripts, or ssr: false route rules automatically fall back to the buffered renderer, so caching and redirect setups keep working.
Living with it: opt out where the response matters
The pragmatic approach is to stream the routes where the response is boring (marketing pages, docs, blog posts, dashboards behind auth) and keep buffering the ones that need to set status codes or cookies based on rendered data. Per-route opt-out uses routeRules:
export default defineNuxtConfig({
experimental: {
ssrStreaming: true,
},
routeRules: {
"/products/**": { streaming: false },
"/checkout/**": { streaming: false },
},
});
For logic that doesn't depend on rendered output, move it earlier instead of opting out. Nitro server middleware runs before the renderer starts, so headers and cookies set there are still in time:
// server/middleware/visitor-id.ts
export default defineEventHandler((event) => {
if (!getCookie(event, "visitor-id")) {
setCookie(event, "visitor-id", crypto.randomUUID(), {
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 365,
});
}
});
A quick checklist before you flip the flag on a real app:
1. Search for setResponseStatus, setHeader, setResponseHeader, and cookie writes in pages/, layouts/, components/, and route middleware.
2. Run the app in dev with streaming on and click through your key routes, watching for dropped-mutation warnings.
3. Decide per route: move the logic into server middleware, or add streaming: false.
4. Check that 404 pages still return 404 (curl with -I is enough), because a soft 404 is an SEO problem you won't see in the browser.
Where this leaves you
SSR streaming is the kind of feature that rewards a careful rollout: turn it on, read the dev warnings, opt out the handful of routes that need control of their response, and measure TTFB and LCP before and after rather than assuming. Because it is still experimental, expect the details to shift as the Nuxt team works toward v5. The Nuxt 4.5 release post and the original pull request are the best places to track the fallback rules and caveats as they evolve.
1 reaction · 1 comment
Nuxt 4.6 nuxt/server: Why Mixing h3 Imports Throws NUXT_E8012
A server/api/profile.get.ts handler that worked fine on Monday starts returning a NUXT_E8012 error on Tuesday, and the only thing that changed is a teammate added one line — reading the signed-in user's session with a helper imported from nuxt/server. Nothing else moved — not the route, not the defineEventHandler at the top of the file, not the dependency versions in any way you'd notice without reading package.json closely. The error message is honest about what's wrong, but only if you already know server/ now has two event shapes floating around, and that this one new line just handed a helper the wrong one.
This article is written against Nuxt 4.6, verified at v4.6.2 (the current latest on npm as of this writing; nuxt/server itself shipped in 4.6.0, released October 5, 2026, per the official GitHub release notes). If you've read the server routes episode in this series, you already know how server/api and server/middleware route a request through Nitro — this one picks up what changed on top of that in 4.6: a second, explicit way to write the exact same kind of file, and the sharp edge where the two meet.
What you'll learn
By the end of this article you'll be able to:
- Explain what nuxt/server is, why Nuxt 4.6 added it, and what it means for Nuxt to be "server-agnostic"
- Tell apart the two coexisting ways to write server code in Nuxt 4.6 — the default h3 auto-imports and the new explicit nuxt/server imports
- Read a NUXT_E8012 error and fix the file it's pointing at
- Spot the subtler bug: helpers with the same name that behave differently depending on which source they came from
- Decide whether to migrate existing server/ code now, or leave it exactly as it is
Who this is for
You've written server/api routes before — defineEventHandler, getQuery, readBody are already familiar. You don't need to have touched Nitro internals or h3 directly; this article treats the new nuxt/server import source as its own subject, not an h3 tutorial.
Table of contents
- [The problem: one new import, one confusing error](#the-problem-one-new-import-one-confusing-error)
- [The mental model: two server APIs, one directory](#the-mental-model-two-server-apis-one-directory)
- [Walking through it, stage by stage](#walking-through-it-stage-by-stage)
- [Edge cases and gotchas](#edge-cases-and-gotchas)
- [Best practices](#best-practices)
- [FAQ](#faq)
- [Cheat sheet](#cheat-sheet)
- [Key takeaways](#key-takeaways)
The problem: one new import, one confusing error
Here's a server/api handler that looks like completely ordinary Nuxt 4 code, with one line added:
// server/api/profile.get.ts
import { useSession } from 'nuxt/server'
export default defineEventHandler((event) => {
const id = getRouterParam(event, 'id')
const session = useSession(event, { password: process.env.NUXT_SESSION_PASSWORD! })
return { id, userId: session.data.userId }
})
defineEventHandler and getRouterParam in this file are untouched — still the default auto-imports, exactly as they were last week. The only new thing is one call to useSession, imported explicitly from nuxt/server, to read the signed-in user's id out of the session. Run the dev server and hit the route, and the response comes back a 500:
[nuxt] NUXT_E8012: `useSession` from `nuxt/server` was called with an h3 event. The
handler was defined with h3's `defineEventHandler` (auto-imported), which passes a
different event. Import `defineEventHandler` from `nuxt/server` alongside the helpers.
The instinct is to assume a typo, a bad auto-import, or a stale .nuxt cache — none of which is true. getRouterParam and the rest of the file's identifiers were never touched, and this isn't a build failure either — the file compiles and the dev server starts just fine. The error only shows up once the route is actually hit, because it's thrown from inside useSession itself, the moment it notices the event it was handed doesn't look like the one nuxt/server expects. That's the whole bug: a handler still built with h3's own defineEventHandler handed one of nuxt/server's session helpers an event it can't work with — and it's also, it turns out, a deliberate guardrail rather than a bug at all.
The mental model: two server APIs, one directory
Before 4.6, server/ had exactly one API: a set of h3 and Nitro helpers (defineEventHandler, getQuery, readBody, getRouterParam, createError, and so on) that Nuxt auto-imports into every file under server/. That hasn't gone away — the release notes are explicit that the auto-imported helpers "are still h3's own helpers," unchanged, and nothing in the 4.6 release requires you to touch them.
What 4.6 adds is nuxt/server — a second, explicit import source, described as "a complement to nuxt/app." Most of what it exports — getQuery, readBody, getRouterParam, and friends — are the exact same underlying h3/Nitro functions, re-exported under a new door, so importing them explicitly from nuxt/server instead of relying on the auto-import changes nothing about how they behave. A few exports are genuinely different, though: nuxt/server ships its own defineEventHandler (the one that builds the portable RequestEvent, with web-standard shape like event.req and event.res.headers), its own createError and sendRedirect with different contracts (more on those below), and its own session API — useSession, getSession, updateSession, clearSession — built specifically to read and write session data off that portable event.
That session API is the clearest place NUXT_E8012 shows up. Each of those four helpers checks the event it's handed: if it looks like an h3 v1 event (no .req, just the legacy node request/response pair) rather than nuxt/server's own event, it throws NUXT_E8012, naming the helper and telling you to import defineEventHandler from nuxt/server too. That only happens if the handler around it is still built with the auto-imported defineEventHandler — the one that still produces an h3 v1 event. Reach for useSession (or one of its three siblings) inside a handler like that, and the event shape it's handed doesn't match what the helper needs. The session helpers aren't the only ones that check — the release notes describe the error more broadly, as something you'll hit from mixing the two import sources in general, so treat the session API as the headline example, not an exhaustive list.
The reason nuxt/server exists at all is the headline of the release: Nuxt is moving toward being server-agnostic — not tied to Nitro as the only possible engine underneath server/. A handler built end-to-end on nuxt/server's portable event is meant to run unchanged whether the engine underneath it is Nitro, or the new experimental @nuxt/vite-server, or — per the release notes — Nitro v3 and h3 v2 once Nuxt 5 ships. Nitro itself isn't going anywhere; the same notes call it "still the right choice for almost everyone," including for 4.6.
Key concept: in this episode's example, NUXT_E8012 comes from nuxt/server's session helpers refusing to work with an event they can't read, because the handler around them never opted into the portable event that makes them work — getQuery, readBody, and getRouterParam don't care which defineEventHandler built the event, so pairing those with the auto-import doesn't trip it. The release notes frame the error more generally, as something mixing the two import sources can produce, so don't assume every other helper is exempt just because these three are.
Walking through it, stage by stage
Stage 1 — the default path needs nothing from you
If you've never heard of nuxt/server, your existing server/api, server/routes, and server/middleware files are unaffected by 4.6. The auto-imported defineEventHandler, getQuery, readBody, getRouterParam, and createError are exactly the h3 helpers they always were. Upgrading to 4.6 changes nothing here — this is the whole point of the release notes' line that "nothing in this release requires a migration."
Stage 2 — opting in, one file at a time
To build a handler around the portable event — and unlock nuxt/server's own defineEventHandler, createError, sendRedirect, and session helpers — you import explicitly, by name, from nuxt/server:
// server/api/health.get.ts — fully opted in
import { defineEventHandler, getQuery, useSession } from 'nuxt/server'
export default defineEventHandler(async (event) => {
const { verbose } = getQuery(event)
const session = await useSession(event, { password: process.env.NUXT_SESSION_PASSWORD! })
return { ok: true, verbose: verbose === 'true', userId: session.data.userId }
})
Because defineEventHandler here is nuxt/server's own version, the event it builds has .req on it — exactly the shape useSession checks for. getQuery behaves identically either way (it's the same h3 helper regardless of source), but the handler itself is opted all the way in, which is what the session API actually needs.
Stage 3 — what actually trips NUXT_E8012
This is the pairing from the mental model above, put to use: switch the handler's defineEventHandler to the nuxt/server import too, and the session helper gets the portable event it's checking for.
// fixed — defineEventHandler now comes from nuxt/server as well
import { defineEventHandler, getRouterParam, useSession } from 'nuxt/server'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const session = await useSession(event, { password: process.env.NUXT_SESSION_PASSWORD! })
return { id, userId: session.data.userId }
})
Notice what doesn't need to change to clear the error: getRouterParam was never what threw NUXT_E8012, whether it came from the auto-import or from nuxt/server — it doesn't check which defineEventHandler built the event the way the session helpers do. The one identifier that has to move to fix this error is defineEventHandler itself, because that's what decides which event shape every helper in the handler gets handed. getRouterParam itself behaves the same from either source, for what it's worth — see the next section for the helpers that actually do differ.
Stage 4 — the quieter trap: same name, different contract
A couple of helpers behave differently depending on which source they came from, and none of these trip NUXT_E8012 either — that error is specific to the session API, not to these. nuxt/server's sendRedirect still sets the response status and Location header as a side effect, exactly like the auto-imported one — but instead of ending the response there, it also returns an HTML <meta http-equiv="refresh"> body as a fallback for a client that ignores the status code (inspecting the installed nuxt@4.6.2 package directly: dist/server/index.js's sendRedirect calls setResponseStatus(event, status) and event.res.headers.set('location', location) before returning that HTML string). So the redirect itself — the part every browser and fetch call actually relies on — works whether or not the handler returns that value; skipping the return only drops the fallback body, which matters solely to a user agent that ignores both the status and the header. Its createError takes status and statusText, where the h3-helper createError you already know takes statusCode and statusMessage — though nuxt/server's createError still accepts statusCode/statusMessage too, as deprecated aliases, so that direction keeps working. And response headers go through event.res.headers, a web-standard Headers-shaped object, rather than h3 v1's own header-setting helpers.
// h3-style (auto-imported) — sends the full response immediately as a side effect
export default defineEventHandler((event) => {
return sendRedirect(event, '/login')
})
// nuxt/server-style — sets status + Location immediately too, then returns a fallback body
import { defineEventHandler, sendRedirect } from 'nuxt/server'
export default defineEventHandler((event) => {
sendRedirect(event, '/login')
// no `return` here — the 302 and Location header are already set, so the
// redirect still happens; only the <meta refresh> fallback page is skipped
})
Neither example throws NUXT_E8012 — that error only ever comes from the session helpers, never from sendRedirect. The real gotcha here is narrower than it looks: dropping the return doesn't break the redirect, it just means a browser gets an empty body alongside the 302 instead of the fallback HTML. Worth knowing, but not worth losing an afternoon over — the loud NUXT_E8012 error from the session helpers is still the one that actually blocks a request.
Stage 5 — deciding whether to opt in at all
Nothing forces you to touch nuxt/server today. The case for doing it anyway, per the release notes, is forward-looking: Nuxt 5 will move to Nitro v3 and h3 v2, and server code written against nuxt/server on 4.6 "runs unchanged there." Code left on the auto-imported h3 helpers isn't broken by this — it keeps working exactly as it did on Nuxt 4.5 — but it's the code that will need attention whenever that migration comes. If you're starting a new server/ file today and have no reason to care about portability, the auto-imports remain the simpler, fully-supported default. If you're building something you expect to still be maintaining past the next major, nuxt/server is the version of "write it once" that this release is actually offering.
Edge cases and gotchas
It's not a per-project switch — it's per call. You can have some server/ files on the auto-imports and others fully on nuxt/server in the same project; most helpers don't care which source built the event they're handed. Only a session-helper call cares, and only at the moment it runs — importing useSession and never calling it is harmless, since the check happens inside the helper itself, not from a static scan of the file's imports.
@nuxt/vite-server is explicitly experimental. The release notes introduce it alongside nuxt/server as a second, pure-Vite server builder, but they're equally explicit that "Nitro is still the right choice for almost everyone" right now. Treat it as a preview of where server-agnostic Nuxt is headed, not a production alternative to reach for in 4.6.
Server-side useState/useRoute still don't belong in either server API. Nothing about nuxt/server changes the boundary the server routes episode in this series covers — both the h3-auto-import world and the nuxt/server world are still the Nitro/h3 side of the project, with no Vue application instance for a composable to attach to.
Best practices
- If a handler calls useSession, getSession, updateSession, or clearSession, make sure that same handler's defineEventHandler is imported from nuxt/server too. That pairing is the one thing NUXT_E8012 actually enforces — get it right and the rest of the file's imports (auto-imported or explicit) don't matter to this particular error.
- Don't migrate existing files just because 4.6 shipped. The release notes are explicit that nothing requires it; migrate a file when you have an actual reason (adopting the session API, building for a new server builder, preparing early for Nuxt 5), not as busywork.
- When you do reach for nuxt/server's own helpers, re-check the contract — not just sendRedirect and createError, but anything that used to write directly to the response. The upgrade guide's comparison table is the source of truth, not assumption-by-familiar-name.
- If a NUXT_E8012 shows up in a file you didn't expect, look for a call to useSession/getSession/updateSession/clearSession and check what built the event around it — it's almost always a defineEventHandler that's still the h3 auto-import.
FAQ
Do I have to migrate my existing server/ code to nuxt/server?
No. The 4.6 release notes say directly that nothing in the release requires a migration — files using only the auto-imported h3 helpers keep working exactly as they did on 4.5.
What exactly causes a NUXT_E8012 error?
Calling one of nuxt/server's session helpers — useSession, getSession, updateSession, or clearSession — inside a handler whose defineEventHandler is still the default auto-import. The helper gets handed an h3 v1 event instead of the portable one it needs, and throws at request time, naming itself in the error. It's not a build-time check and it's not about any other helper pairing — getQuery, readBody, and getRouterParam behave identically no matter which source you import them from.
Is nuxt/server replacing Nitro?
No. Nitro remains the default engine underneath both import styles in 4.6, and the release notes call it "still the right choice for almost everyone." nuxt/server is a portable way to write handlers that happen to run on Nitro today and could run on a different server builder later — it isn't a replacement for Nitro itself.
Do sendRedirect, createError, and getRouterParam behave the same regardless of where I import them from?
Mostly, but not entirely. getRouterParam/getRouterParams behave identically either way — both default to the raw, undecoded route segment, and both accept { decode: true } to decode it. createError does differ: nuxt/server's version takes status/statusText (it still accepts statusCode/statusMessage too, as deprecated aliases), while the auto-imported h3 helper only recognizes statusCode/statusMessage. sendRedirect sets the status and Location header as a side effect either way, then additionally returns a <meta refresh> fallback body from the nuxt/server version — skip the return and the redirect still happens, you just lose that fallback body. None of this is NUXT_E8012, which comes only from the session API checking the event shape it's handed.
Should I use @nuxt/vite-server instead of Nitro?
Not yet, for anything you're shipping. The release notes introduce it as experimental, in the same breath as recommending Nitro for almost everyone. It's worth knowing it exists as a sign of where Nuxt's server layer is heading, not as a current production choice.
Cheat sheet
| Situation | What to do |
| --- | --- |
| New server/ file, no portability need | Leave it on the default auto-imports — no import needed |
| Want a handler portable beyond Nitro | Import everything server-related in that file from nuxt/server |
| Calling getQuery/readBody from either source | Behaves identically — they're the same h3 helper either way |
| Calling getRouterParam/getRouterParams from either source | Behaves identically — both default to the raw, undecoded segment; pass { decode: true } on either to decode it |
| Calling useSession/getSession/updateSession/clearSession | Only works if that handler's defineEventHandler is also from nuxt/server |
| NUXT_E8012 in a file you didn't expect | Find the session-helper call and check whether its handler still uses the auto-imported defineEventHandler |
| Redirecting with the auto-imported sendRedirect | Just call it — it ends the response immediately as a side effect |
| Redirecting with nuxt/server's sendRedirect | Sets status + Location immediately too; return sendRedirect(event, path) additionally sends a fallback body for clients that ignore the status |
| Throwing with the auto-imported createError | { statusCode, statusMessage } |
| Throwing with nuxt/server's createError | { status, statusText } |
| Deciding whether to migrate at all | Only if you have a real reason — nothing requires it on 4.6 |
| Trying @nuxt/vite-server | Experimental — fine to explore, not yet for production |
<!-- playground:start -->
🎮 Try it yourself
▶️ Open the interactive playground →
_Runs right in your browser — poke at it and watch the concept react live._
<!-- playground:end -->
Key takeaways
- Nuxt 4.6 added nuxt/server, a new explicit import source for server code, alongside the default auto-imported h3 helpers that server/ has always had — most of what it exports is the same h3/Nitro functions under a new door, not a parallel implementation.
- NUXT_E8012 is narrower than "don't mix imports": it fires specifically when nuxt/server's session helpers (useSession, getSession, updateSession, clearSession) are called inside a handler whose defineEventHandler is still the auto-import — the fix is pairing that one import with nuxt/server too.
- createError shares a name across both sources but not a contract (status/statusText vs. statusCode/statusMessage), and sendRedirect sets its status and Location header the same way in both but only returns a fallback body from nuxt/server — skipping that return loses the fallback body, not the redirect. getRouterParam/getRouterParams behave identically from either source. None of these throw NUXT_E8012.
- Migrating existing code to nuxt/server is optional today, and mainly worth it if you want a handler to keep working unchanged once Nuxt 5 moves to Nitro v3 and h3 v2.
<!-- quiz:start -->
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._
<!-- quiz:end -->
The teammate's one-line import, explained
That NUXT_E8012 from the top of the article wasn't a cache problem or a typo — it was nuxt/server's session API refusing to read a session off an event it doesn't recognize. Once you know the error belongs specifically to the session helpers, not to every helper in the file, it stops looking like a mystery and starts looking like a guardrail with a narrow, specific job — and the subtler case, the same-named helper that behaves differently with no error at all, is the one worth remembering long after a request stops failing.
<!-- related:start -->
📚 Read next
- Nuxt Server Routes Explained: How Nitro Builds Your API
- Nuxt Hydration Mismatch: Why It Happens and How to Fix It
- Nuxt 4.5 SSR Streaming: The Route Rules That Disable It
<!-- related:end -->
---
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg
- 📸 Instagram — frontend best practices, daily: @bestpractice___
7 reactions
Mentions
Nuxt CLI v4 Is Great for AI Coding Agents Have you ever had a dev server running, and then asked your coding agent to test something in that app? It starts its own server on port 3001. Then it restarts that one, and you end up with another on 3002. Pretty soon you've lost track of what's running on which port and what you need to stop... Nuxt CLI v4 shipped alongside Nuxt 4.6 in early October, and a few of its new features fix that exact problem. The Nuxt team pitched this release as a speed and dev experience update. Nuxt's own numbers put the CLI install at 3.5 MB (down from 13.1 MB) and first paint for nuxt dev at 50 ms (down from 330 ms). However, what I'm really interested in is some of the agentic features. I tried them on a small Nuxt 4.6.4 todo app. Here's the video if you'd rather watch: {% embed youtube.com %} Prerequisites - Node.js 22.21+, 24.11+, or 26+. CLI v4 won't run on older versions. - A Nuxt project on 4.6 or later. CLI v4 is a dependency of nuxt, so upgrading Nuxt gets you the new CLI. I tested nuxt 4.6.4 with @nuxt/cli 4.0.3. - Optional, a coding agent that can run shell commands. 1. Meet the new nuxt dev Run npm run dev and the first thing you'll notice is the panel pinned to the bottom of your terminal. It shows your Nuxt version, the local URL, a request counter, and a row of single-key shortcuts. !The new nuxt dev terminal UI showing Nuxt 4.6.4, the local URL, 36 requests with an 11ms median, a READY badge, and shortcuts for restart, open, info, logs, network, routes, help, and quit The new nuxt dev panel. Each letter at the bottom is a shortcut. r restarts the server, o opens it in the browser, i shows info about your setup, and n lists every request with its status and timing. Press p for all your pages and server routes. If you'd rather have plain log output, nuxt dev --no-tui turns the panel off. 2. Check the lock file When nuxt dev starts, it writes .nuxt/nuxt.lock. When you stop the server, the file goes away. Here's mine: !The .nuxt/nuxt.lock file open in the editor with pid, startedAt, interactive, command, cwd, parentPid, port 8231, hostname 127.0.0.1, and url fields .nuxt/nuxt.lock has everything an agent needs to find the running server. The PID, port, and URL tell an agent that a server is already up and where to find it. It doesn't have to probe ports or guess at localhost:3000. The interactive field records whether the server was started from an interactive terminal. Nuxt uses it to decide what happens when a second nuxt dev shows up. In my testing, a server started from a real terminal wouldn't hand itself over to a non-interactive nuxt dev (the kind an agent runs). The agent gets a message pointing it at --takeover instead. A server that was started non-interactively gets taken over on the same port, so an agent that restarts its own server doesn't leave the old one running. 3. Run nuxt dev a second time If you run npm run dev again while a server is up, Nuxt stops and asks what you want: !Terminal output reading "A Nuxt dev server is already running here (PID 107817, http://127.0.0.1:8231)" followed by three options, Take over port 8231, Do not start, and Start anyway Taking over stops the running server. Start anyway gives you a second one. Take over stops the running server and starts yours on the same port. That's the --takeover flag. Start anyway runs a second server, which you can also force with NUXT_IGNORE_LOCK=1. Nuxt calls that unsupported. The CLI already warns an agent about all of this, but I still wanted the rules written down. So I added a steering file at .kiro/steering/nuxt-dev-server.md. If you use a different agent, put the same thing in AGENTS.md. --- inclusion: always --- # Nuxt dev server rules This project uses Nuxt 4.6 with Nuxt CLI v4. - Never start a second `nuxt dev`. A dev server is usually already running. Check `.nuxt/nuxt.lock` for its URL. - Never pass `--takeover` or set `NUXT_IGNORE_LOCK=1` unless I ask. - To test API routes, use `npx nuxt curl <path>` (add `-X`, `-d`, `-i` as needed). It finds the running server without a port. - To run Nitro tasks, use `npx nuxt task list` and `npx nuxt task run <name> --payload.<key>=<value>`. - Before using a Nuxt API you are unsure about, run `npx nuxt docs "<query>"` to read the docs for the installed version. 4. Test routes with nuxt curl If the agent can't start its own server, it still needs a way to test things. That's what nuxt curl is for. npx nuxt curl /api/todos !Terminal running npx nuxt curl /api/todos and printing a formatted JSON array with two todos, Upgrade to Nuxt 4.6 and Let Kiro find my dev server No host, no port. nuxt curl reads the lock file. You pass a path, not a URL. nuxt curl reads the lock file, finds the running server, and pretty-prints the JSON it gets back. It takes the usual curl flags too, so a POST looks like this: npx nuxt curl /api/todos -X POST -d '{"title":"Added by nuxt curl"}' Add -i to see the response headers. If no server is running, it tells you to start one with nuxt dev or pass an absolute URL. 5. Run Nitro tasks with nuxt task The other command I liked is nuxt task, which runs Nitro tasks against the running server. Tasks are still experimental in Nitro, so turn them on in nuxt.config.ts first: export default defineNuxtConfig({ nitro: { experimental: { tasks: true }, }, }) My app has a seed task in server/tasks/db/seed.ts that resets the in-memory todos: export default defineTask({ meta: { name: 'db:seed', description: 'Replace the in-memory todos with N seeded todos', }, run({ payload }) { const count = Math.min(Math.max(Number(payload?.count) || 5, 1), 100) const todos = resetTodos(count) return { result: `Seeded ${todos.length} todos` } }, }) Then list and run it from the terminal: npx nuxt task list npx nuxt task run db:seed !Terminal showing npx nuxt task list returning db:seed, a failed npx nuxt task db:seed with an Unknown command error and usage help, then npx nuxt task run db:seed printing Seeded 5 todos I forgot the run subcommand the first time. The help output got me there. You can see I typed npx nuxt task db:seed first and got an error. It needs run. Pass a payload with --payload.count=10 and you get 10 todos instead of the default 5. 6. Hand errors to your agent I really like this the most, because I run into this all the time! In my example my app has a /profile page that's broken on purpose: <script setup lang="ts"> const user = { name: 'Erik', profile: null as null | { handle: string } } const handle = user.profile!.handle.toUpperCase() </script> Load it and Nuxt shows the new error overlay. You get the error, the exact line in profile.vue, a toggle between source and compiled code, and a call stack with the 9 framework frames hidden by default. !The Nuxt error overlay showing TypeError Cannot read properties of null (reading 'handle') at app/pages/profile.vue:5:30, with the Copy error menu open listing Copy as Markdown, Copy prompt for an agent, and Copy structured JSON The Copy error menu has an option written for agents. The new part is the Copy error menu. Copy as Markdown and Copy structured JSON do what they sound like. Copy prompt for an agent wraps the error, source location, Node version, and runtime details in instructions to diagnose the error, trace the root cause, and explain the failure. I pasted that prompt into Kiro, running Claude Opus 5.5, and let it go. Because of the steering file, it didn't start a server. It ran npx nuxt curl /profile to confirm the 500, changed the non-null assertion to optional chaining and added a v-if so the template skips the missing handle, then ran nuxt curl again to confirm a 200. !Kiro chat panel showing the verification steps. Before, npx nuxt curl /profile returned 500 with the profile.vue:5:30 stack trace. After, it returns 200 and renders the section. The file has no type errors Kiro checked its own fix with nuxt curl against my running server. The whole thing took about 36 seconds and one credit. Wrap up I'm really liking this new version of Nuxt CLI! I'd like to see more frameworks do this. Have you had an agent take over your dev server, or start five of them? Let me know in the comments. Links - Nuxt 4.6 release post - Nuxt CLI v4.0.0 release notes - Nitro tasks docs - Kiro steering docs - Kiro - The video on YouTube
Framework Tracker compares two benchmarks: 1,000 router link components versus plain a tags, showing the components’ server-rendering cost. @danielroe.dev's PR benchmark showed rendering 200 internal NuxtLinks on the server dropping from 1.36ms to 0.57ms. PR: nuxt#36015
please note, this only affects the default `ipx` provider on server deployments. platform-specific providers and edge/worker runtimes don't register the route. you can find out more in: 👉 `@nuxt/image`: github.com/nuxt/image/security/advisories/GHSA-p5… 👉 `ipx`: github.com/unjs/ipx/security/advisories/GHSA-qf89…
🚨 `@nuxt/image` security update 🚨 a single unauthenticated request to the default `/_ipx` route can crash your server, and may allow remote code execution, via a crafted multi-page image. 👉 this is fixed in 2.2.1 and 1.11.2, by bumping `ipx` github.com/nuxt/image/releases/tag/v2.2.1
🚨 `@nuxt/image` security update 🚨 a single unauthenticated request to the default `/_ipx` route can crash your server, and may allow remote code execution, via a crafted multi-page image. 👉 this is fixed in 2.2.1 and 1.11.2, by bumping `ipx` github.com/nuxt/image/releases/tag/v2.2.1
I asked Opus to teach me my own Nuxt PR. It came back with a 3 minute video: voiceover, animations synced to every word, and a quiz at the end. nuxt#36786 t.co
It's happening! 🔥 Nuxt is moving to Nitro 3. Can't wait to see what's coming next! nuxt#36671
the latest nuxt nightly version with nitro rc is out now if you want to play around! 🔥
Nuxt SSR Streaming: Faster TTFB and the Response You Can't Change Later Classic server rendering in Nuxt is all or nothing. The server runs every plugin, resolves every awaited useFetch, renders the whole Vue tree to a string, and only then sends a single byte back. If one API call on the page takes 600ms, the browser sits staring at a blank tab for 600ms, unable to even start downloading your CSS. Streaming SSR has been one of the longest-standing open issues in the Nuxt repo, and as of Nuxt 4.5 it finally ships behind a flag. The win is real, but it comes with a contract change that is easy to trip over. Once the first byte is on the wire, the HTTP status and headers are locked in. Let's look at both halves. Turning it on Streaming is experimental and off by default. Enable it in nuxt.config.ts: export default defineNuxtConfig({ experimental: { ssrStreaming: true, }, }); With this on, Nuxt immediately flushes an HTML shell (your <head>, styles, preload hints, and entry scripts) and then streams the body as Vue renders it. The browser can start fetching CSS, fonts, and the JS entry chunk while your server is still waiting on that slow API. Bots and crawlers are excluded by default, so Googlebot and friends still get a fully buffered response. If you have an internal crawler or a link-preview service that chokes on streamed HTML, you can widen the match: export default defineNuxtConfig({ experimental: { ssrStreaming: { botRegex: /googlebot|bingbot|slackbot|my-internal-crawler/i, }, }, }); Be realistic about what improves. Time to First Byte drops reliably, because the shell no longer waits on your data. Largest Contentful Paint _may_ improve, depending on how much of the route's JavaScript overlaps the entry chunk and whether your LCP element sits behind the slow fetch. A page whose hero is rendered from an awaited API call still has to wait for that call before the hero arrives; you just stop wasting that time on idle network. The rule: the response is committed at byte one HTTP sends the status line and headers before the body. A buffered renderer can change its mind right up until the end. A streaming renderer cannot. Anything that tries to mutate the response _after_ rendering has begun simply can't reach the client. That covers more code than you might expect: <!-- pages/products/[slug].vue --> <script setup lang="ts"> const route = useRoute(); const { data: product } = await useFetch(`/api/products/${route.params.slug}`); if (!product.value) { // Too late under streaming: a 200 has already been sent setResponseStatus(useRequestEvent()!, 404); } // Also too late: the Set-Cookie header is already gone const lastViewed = useCookie("last-viewed"); lastViewed.value = String(route.params.slug); </script> The release notes call out setResponseStatus() in <script setup> and cookie writes during middleware as typical cases. Nuxt does not fail silently here: in development it logs a warning naming each dropped mutation. Treat those warnings as bugs, not noise. Nuxt also handles the common cases for you. Routes with redirect, cache, isr, swr, noScripts, or ssr: false route rules automatically fall back to the buffered renderer, so caching and redirect setups keep working. Living with it: opt out where the response matters The pragmatic approach is to stream the routes where the response is boring (marketing pages, docs, blog posts, dashboards behind auth) and keep buffering the ones that need to set status codes or cookies based on rendered data. Per-route opt-out uses routeRules: export default defineNuxtConfig({ experimental: { ssrStreaming: true, }, routeRules: { "/products/**": { streaming: false }, "/checkout/**": { streaming: false }, }, }); For logic that doesn't depend on rendered output, move it earlier instead of opting out. Nitro server middleware runs before the renderer starts, so headers and cookies set there are still in time: // server/middleware/visitor-id.ts export default defineEventHandler((event) => { if (!getCookie(event, "visitor-id")) { setCookie(event, "visitor-id", crypto.randomUUID(), { httpOnly: true, sameSite: "lax", maxAge: 60 * 60 * 24 * 365, }); } }); A quick checklist before you flip the flag on a real app: 1. Search for setResponseStatus, setHeader, setResponseHeader, and cookie writes in pages/, layouts/, components/, and route middleware. 2. Run the app in dev with streaming on and click through your key routes, watching for dropped-mutation warnings. 3. Decide per route: move the logic into server middleware, or add streaming: false. 4. Check that 404 pages still return 404 (curl with -I is enough), because a soft 404 is an SEO problem you won't see in the browser. Where this leaves you SSR streaming is the kind of feature that rewards a careful rollout: turn it on, read the dev warnings, opt out the handful of routes that need control of their response, and measure TTFB and LCP before and after rather than assuming. Because it is still experimental, expect the details to shift as the Nuxt team works toward v5. The Nuxt 4.5 release post and the original pull request are the best places to track the fallback rules and caveats as they evolve.
Nuxt 4.6 nuxt/server: Why Mixing h3 Imports Throws NUXT_E8012 A server/api/profile.get.ts handler that worked fine on Monday starts returning a NUXT_E8012 error on Tuesday, and the only thing that changed is a teammate added one line — reading the signed-in user's session with a helper imported from nuxt/server. Nothing else moved — not the route, not the defineEventHandler at the top of the file, not the dependency versions in any way you'd notice without reading package.json closely. The error message is honest about what's wrong, but only if you already know server/ now has two event shapes floating around, and that this one new line just handed a helper the wrong one. This article is written against Nuxt 4.6, verified at v4.6.2 (the current latest on npm as of this writing; nuxt/server itself shipped in 4.6.0, released October 5, 2026, per the official GitHub release notes). If you've read the server routes episode in this series, you already know how server/api and server/middleware route a request through Nitro — this one picks up what changed on top of that in 4.6: a second, explicit way to write the exact same kind of file, and the sharp edge where the two meet. What you'll learn By the end of this article you'll be able to: - Explain what nuxt/server is, why Nuxt 4.6 added it, and what it means for Nuxt to be "server-agnostic" - Tell apart the two coexisting ways to write server code in Nuxt 4.6 — the default h3 auto-imports and the new explicit nuxt/server imports - Read a NUXT_E8012 error and fix the file it's pointing at - Spot the subtler bug: helpers with the same name that behave differently depending on which source they came from - Decide whether to migrate existing server/ code now, or leave it exactly as it is Who this is for You've written server/api routes before — defineEventHandler, getQuery, readBody are already familiar. You don't need to have touched Nitro internals or h3 directly; this article treats the new nuxt/server import source as its own subject, not an h3 tutorial. Table of contents - [The problem: one new import, one confusing error](#the-problem-one-new-import-one-confusing-error) - [The mental model: two server APIs, one directory](#the-mental-model-two-server-apis-one-directory) - [Walking through it, stage by stage](#walking-through-it-stage-by-stage) - [Edge cases and gotchas](#edge-cases-and-gotchas) - [Best practices](#best-practices) - [FAQ](#faq) - [Cheat sheet](#cheat-sheet) - [Key takeaways](#key-takeaways) The problem: one new import, one confusing error Here's a server/api handler that looks like completely ordinary Nuxt 4 code, with one line added: // server/api/profile.get.ts import { useSession } from 'nuxt/server' export default defineEventHandler((event) => { const id = getRouterParam(event, 'id') const session = useSession(event, { password: process.env.NUXT_SESSION_PASSWORD! }) return { id, userId: session.data.userId } }) defineEventHandler and getRouterParam in this file are untouched — still the default auto-imports, exactly as they were last week. The only new thing is one call to useSession, imported explicitly from nuxt/server, to read the signed-in user's id out of the session. Run the dev server and hit the route, and the response comes back a 500: [nuxt] NUXT_E8012: `useSession` from `nuxt/server` was called with an h3 event. The handler was defined with h3's `defineEventHandler` (auto-imported), which passes a different event. Import `defineEventHandler` from `nuxt/server` alongside the helpers. The instinct is to assume a typo, a bad auto-import, or a stale .nuxt cache — none of which is true. getRouterParam and the rest of the file's identifiers were never touched, and this isn't a build failure either — the file compiles and the dev server starts just fine. The error only shows up once the route is actually hit, because it's thrown from inside useSession itself, the moment it notices the event it was handed doesn't look like the one nuxt/server expects. That's the whole bug: a handler still built with h3's own defineEventHandler handed one of nuxt/server's session helpers an event it can't work with — and it's also, it turns out, a deliberate guardrail rather than a bug at all. The mental model: two server APIs, one directory Before 4.6, server/ had exactly one API: a set of h3 and Nitro helpers (defineEventHandler, getQuery, readBody, getRouterParam, createError, and so on) that Nuxt auto-imports into every file under server/. That hasn't gone away — the release notes are explicit that the auto-imported helpers "are still h3's own helpers," unchanged, and nothing in the 4.6 release requires you to touch them. What 4.6 adds is nuxt/server — a second, explicit import source, described as "a complement to nuxt/app." Most of what it exports — getQuery, readBody, getRouterParam, and friends — are the exact same underlying h3/Nitro functions, re-exported under a new door, so importing them explicitly from nuxt/server instead of relying on the auto-import changes nothing about how they behave. A few exports are genuinely different, though: nuxt/server ships its own defineEventHandler (the one that builds the portable RequestEvent, with web-standard shape like event.req and event.res.headers), its own createError and sendRedirect with different contracts (more on those below), and its own session API — useSession, getSession, updateSession, clearSession — built specifically to read and write session data off that portable event. That session API is the clearest place NUXT_E8012 shows up. Each of those four helpers checks the event it's handed: if it looks like an h3 v1 event (no .req, just the legacy node request/response pair) rather than nuxt/server's own event, it throws NUXT_E8012, naming the helper and telling you to import defineEventHandler from nuxt/server too. That only happens if the handler around it is still built with the auto-imported defineEventHandler — the one that still produces an h3 v1 event. Reach for useSession (or one of its three siblings) inside a handler like that, and the event shape it's handed doesn't match what the helper needs. The session helpers aren't the only ones that check — the release notes describe the error more broadly, as something you'll hit from mixing the two import sources in general, so treat the session API as the headline example, not an exhaustive list. The reason nuxt/server exists at all is the headline of the release: Nuxt is moving toward being server-agnostic — not tied to Nitro as the only possible engine underneath server/. A handler built end-to-end on nuxt/server's portable event is meant to run unchanged whether the engine underneath it is Nitro, or the new experimental @nuxt/vite-server, or — per the release notes — Nitro v3 and h3 v2 once Nuxt 5 ships. Nitro itself isn't going anywhere; the same notes call it "still the right choice for almost everyone," including for 4.6. Key concept: in this episode's example, NUXT_E8012 comes from nuxt/server's session helpers refusing to work with an event they can't read, because the handler around them never opted into the portable event that makes them work — getQuery, readBody, and getRouterParam don't care which defineEventHandler built the event, so pairing those with the auto-import doesn't trip it. The release notes frame the error more generally, as something mixing the two import sources can produce, so don't assume every other helper is exempt just because these three are. Walking through it, stage by stage Stage 1 — the default path needs nothing from you If you've never heard of nuxt/server, your existing server/api, server/routes, and server/middleware files are unaffected by 4.6. The auto-imported defineEventHandler, getQuery, readBody, getRouterParam, and createError are exactly the h3 helpers they always were. Upgrading to 4.6 changes nothing here — this is the whole point of the release notes' line that "nothing in this release requires a migration." Stage 2 — opting in, one file at a time To build a handler around the portable event — and unlock nuxt/server's own defineEventHandler, createError, sendRedirect, and session helpers — you import explicitly, by name, from nuxt/server: // server/api/health.get.ts — fully opted in import { defineEventHandler, getQuery, useSession } from 'nuxt/server' export default defineEventHandler(async (event) => { const { verbose } = getQuery(event) const session = await useSession(event, { password: process.env.NUXT_SESSION_PASSWORD! }) return { ok: true, verbose: verbose === 'true', userId: session.data.userId } }) Because defineEventHandler here is nuxt/server's own version, the event it builds has .req on it — exactly the shape useSession checks for. getQuery behaves identically either way (it's the same h3 helper regardless of source), but the handler itself is opted all the way in, which is what the session API actually needs. Stage 3 — what actually trips NUXT_E8012 This is the pairing from the mental model above, put to use: switch the handler's defineEventHandler to the nuxt/server import too, and the session helper gets the portable event it's checking for. // fixed — defineEventHandler now comes from nuxt/server as well import { defineEventHandler, getRouterParam, useSession } from 'nuxt/server' export default defineEventHandler(async (event) => { const id = getRouterParam(event, 'id') const session = await useSession(event, { password: process.env.NUXT_SESSION_PASSWORD! }) return { id, userId: session.data.userId } }) Notice what doesn't need to change to clear the error: getRouterParam was never what threw NUXT_E8012, whether it came from the auto-import or from nuxt/server — it doesn't check which defineEventHandler built the event the way the session helpers do. The one identifier that has to move to fix this error is defineEventHandler itself, because that's what decides which event shape every helper in the handler gets handed. getRouterParam itself behaves the same from either source, for what it's worth — see the next section for the helpers that actually do differ. Stage 4 — the quieter trap: same name, different contract A couple of helpers behave differently depending on which source they came from, and none of these trip NUXT_E8012 either — that error is specific to the session API, not to these. nuxt/server's sendRedirect still sets the response status and Location header as a side effect, exactly like the auto-imported one — but instead of ending the response there, it also returns an HTML <meta http-equiv="refresh"> body as a fallback for a client that ignores the status code (inspecting the installed nuxt@4.6.2 package directly: dist/server/index.js's sendRedirect calls setResponseStatus(event, status) and event.res.headers.set('location', location) before returning that HTML string). So the redirect itself — the part every browser and fetch call actually relies on — works whether or not the handler returns that value; skipping the return only drops the fallback body, which matters solely to a user agent that ignores both the status and the header. Its createError takes status and statusText, where the h3-helper createError you already know takes statusCode and statusMessage — though nuxt/server's createError still accepts statusCode/statusMessage too, as deprecated aliases, so that direction keeps working. And response headers go through event.res.headers, a web-standard Headers-shaped object, rather than h3 v1's own header-setting helpers. // h3-style (auto-imported) — sends the full response immediately as a side effect export default defineEventHandler((event) => { return sendRedirect(event, '/login') }) // nuxt/server-style — sets status + Location immediately too, then returns a fallback body import { defineEventHandler, sendRedirect } from 'nuxt/server' export default defineEventHandler((event) => { sendRedirect(event, '/login') // no `return` here — the 302 and Location header are already set, so the // redirect still happens; only the <meta refresh> fallback page is skipped }) Neither example throws NUXT_E8012 — that error only ever comes from the session helpers, never from sendRedirect. The real gotcha here is narrower than it looks: dropping the return doesn't break the redirect, it just means a browser gets an empty body alongside the 302 instead of the fallback HTML. Worth knowing, but not worth losing an afternoon over — the loud NUXT_E8012 error from the session helpers is still the one that actually blocks a request. Stage 5 — deciding whether to opt in at all Nothing forces you to touch nuxt/server today. The case for doing it anyway, per the release notes, is forward-looking: Nuxt 5 will move to Nitro v3 and h3 v2, and server code written against nuxt/server on 4.6 "runs unchanged there." Code left on the auto-imported h3 helpers isn't broken by this — it keeps working exactly as it did on Nuxt 4.5 — but it's the code that will need attention whenever that migration comes. If you're starting a new server/ file today and have no reason to care about portability, the auto-imports remain the simpler, fully-supported default. If you're building something you expect to still be maintaining past the next major, nuxt/server is the version of "write it once" that this release is actually offering. Edge cases and gotchas It's not a per-project switch — it's per call. You can have some server/ files on the auto-imports and others fully on nuxt/server in the same project; most helpers don't care which source built the event they're handed. Only a session-helper call cares, and only at the moment it runs — importing useSession and never calling it is harmless, since the check happens inside the helper itself, not from a static scan of the file's imports. @nuxt/vite-server is explicitly experimental. The release notes introduce it alongside nuxt/server as a second, pure-Vite server builder, but they're equally explicit that "Nitro is still the right choice for almost everyone" right now. Treat it as a preview of where server-agnostic Nuxt is headed, not a production alternative to reach for in 4.6. Server-side useState/useRoute still don't belong in either server API. Nothing about nuxt/server changes the boundary the server routes episode in this series covers — both the h3-auto-import world and the nuxt/server world are still the Nitro/h3 side of the project, with no Vue application instance for a composable to attach to. Best practices - If a handler calls useSession, getSession, updateSession, or clearSession, make sure that same handler's defineEventHandler is imported from nuxt/server too. That pairing is the one thing NUXT_E8012 actually enforces — get it right and the rest of the file's imports (auto-imported or explicit) don't matter to this particular error. - Don't migrate existing files just because 4.6 shipped. The release notes are explicit that nothing requires it; migrate a file when you have an actual reason (adopting the session API, building for a new server builder, preparing early for Nuxt 5), not as busywork. - When you do reach for nuxt/server's own helpers, re-check the contract — not just sendRedirect and createError, but anything that used to write directly to the response. The upgrade guide's comparison table is the source of truth, not assumption-by-familiar-name. - If a NUXT_E8012 shows up in a file you didn't expect, look for a call to useSession/getSession/updateSession/clearSession and check what built the event around it — it's almost always a defineEventHandler that's still the h3 auto-import. FAQ Do I have to migrate my existing server/ code to nuxt/server? No. The 4.6 release notes say directly that nothing in the release requires a migration — files using only the auto-imported h3 helpers keep working exactly as they did on 4.5. What exactly causes a NUXT_E8012 error? Calling one of nuxt/server's session helpers — useSession, getSession, updateSession, or clearSession — inside a handler whose defineEventHandler is still the default auto-import. The helper gets handed an h3 v1 event instead of the portable one it needs, and throws at request time, naming itself in the error. It's not a build-time check and it's not about any other helper pairing — getQuery, readBody, and getRouterParam behave identically no matter which source you import them from. Is nuxt/server replacing Nitro? No. Nitro remains the default engine underneath both import styles in 4.6, and the release notes call it "still the right choice for almost everyone." nuxt/server is a portable way to write handlers that happen to run on Nitro today and could run on a different server builder later — it isn't a replacement for Nitro itself. Do sendRedirect, createError, and getRouterParam behave the same regardless of where I import them from? Mostly, but not entirely. getRouterParam/getRouterParams behave identically either way — both default to the raw, undecoded route segment, and both accept { decode: true } to decode it. createError does differ: nuxt/server's version takes status/statusText (it still accepts statusCode/statusMessage too, as deprecated aliases), while the auto-imported h3 helper only recognizes statusCode/statusMessage. sendRedirect sets the status and Location header as a side effect either way, then additionally returns a <meta refresh> fallback body from the nuxt/server version — skip the return and the redirect still happens, you just lose that fallback body. None of this is NUXT_E8012, which comes only from the session API checking the event shape it's handed. Should I use @nuxt/vite-server instead of Nitro? Not yet, for anything you're shipping. The release notes introduce it as experimental, in the same breath as recommending Nitro for almost everyone. It's worth knowing it exists as a sign of where Nuxt's server layer is heading, not as a current production choice. Cheat sheet | Situation | What to do | | --- | --- | | New server/ file, no portability need | Leave it on the default auto-imports — no import needed | | Want a handler portable beyond Nitro | Import everything server-related in that file from nuxt/server | | Calling getQuery/readBody from either source | Behaves identically — they're the same h3 helper either way | | Calling getRouterParam/getRouterParams from either source | Behaves identically — both default to the raw, undecoded segment; pass { decode: true } on either to decode it | | Calling useSession/getSession/updateSession/clearSession | Only works if that handler's defineEventHandler is also from nuxt/server | | NUXT_E8012 in a file you didn't expect | Find the session-helper call and check whether its handler still uses the auto-imported defineEventHandler | | Redirecting with the auto-imported sendRedirect | Just call it — it ends the response immediately as a side effect | | Redirecting with nuxt/server's sendRedirect | Sets status + Location immediately too; return sendRedirect(event, path) additionally sends a fallback body for clients that ignore the status | | Throwing with the auto-imported createError | { statusCode, statusMessage } | | Throwing with nuxt/server's createError | { status, statusText } | | Deciding whether to migrate at all | Only if you have a real reason — nothing requires it on 4.6 | | Trying @nuxt/vite-server | Experimental — fine to explore, not yet for production | <!-- playground:start --> 🎮 Try it yourself ▶️ Open the interactive playground → _Runs right in your browser — poke at it and watch the concept react live._ <!-- playground:end --> Key takeaways - Nuxt 4.6 added nuxt/server, a new explicit import source for server code, alongside the default auto-imported h3 helpers that server/ has always had — most of what it exports is the same h3/Nitro functions under a new door, not a parallel implementation. - NUXT_E8012 is narrower than "don't mix imports": it fires specifically when nuxt/server's session helpers (useSession, getSession, updateSession, clearSession) are called inside a handler whose defineEventHandler is still the auto-import — the fix is pairing that one import with nuxt/server too. - createError shares a name across both sources but not a contract (status/statusText vs. statusCode/statusMessage), and sendRedirect sets its status and Location header the same way in both but only returns a fallback body from nuxt/server — skipping that return loses the fallback body, not the redirect. getRouterParam/getRouterParams behave identically from either source. None of these throw NUXT_E8012. - Migrating existing code to nuxt/server is optional today, and mainly worth it if you want a handler to keep working unchanged once Nuxt 5 moves to Nitro v3 and h3 v2. <!-- quiz:start --> 🧠 Test yourself Think it clicked? Take the 8-question quiz → _Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._ <!-- quiz:end --> The teammate's one-line import, explained That NUXT_E8012 from the top of the article wasn't a cache problem or a typo — it was nuxt/server's session API refusing to read a session off an event it doesn't recognize. Once you know the error belongs specifically to the session helpers, not to every helper in the file, it stops looking like a mystery and starts looking like a guardrail with a narrow, specific job — and the subtler case, the same-named helper that behaves differently with no error at all, is the one worth remembering long after a request stops failing. <!-- related:start --> 📚 Read next - Nuxt Server Routes Explained: How Nitro Builds Your API - Nuxt Hydration Mismatch: Why It Happens and How to Fix It - Nuxt 4.5 SSR Streaming: The Route Rules That Disable It <!-- related:end --> --- 🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you. Thanks for reading! Let's stay connected: - ⭐ GitHub — follow me and star the projects: github.com/parsajiravand - 💬 Discord — join the frontend best-practices community: discord.gg - 📸 Instagram — frontend best practices, daily: @bestpractice___