Skip to content
Release notesNuxt

What's new in Nuxt 4.6: nuxt/server, typed $fetch, cookie sessions and Vapor

Nuxt 4.6 was released on 5 October, together with Nuxt CLI v4. Nothing in it requires a migration, but your server routes have a new way to be written, and it is the one that carries over to Nuxt 5.

vuejs.school team6 Oct 20267 min readNuxt 4.6 · Nuxt CLI v4

Nuxt 4.6 came out on 5 October, and the release post calls it one of the biggest minor releases yet: over 420 commits since 4.5.2, shipped alongside a new major of the command line tool, Nuxt CLI v4. Its main theme is making Nuxt server-agnostic, so server code no longer has to depend on the exact versions of Nitro and h3, the libraries that run Nuxt's server.

Nothing in 4.6 requires a migration, but it raises the minimum Node version. The biggest changes are for server routes: a nuxt/server import that carries over to Nuxt 5, and sessions stored in a cookie. The examples use the app/ directory for Vue code and server/ at the project root.

Write server routes with nuxt/server

nuxt/server is a new import source for handlers, middleware and utilities. It is the server counterpart of nuxt/app. The helpers use web standards and receive a portable RequestEvent: event.req is a standard Request, event.url a URL, and event.res.headers a standard Headers object you write response headers to.

This route adds a note for the signed-in user. The session it reads is set by the sign-in route in the next section:

server/api/notes.post.ts
import { createError, defineEventHandler, readValidatedBody, useSession } from 'nuxt/server'
import { z } from 'zod'

const NewNote = z.object({ text: z.string().trim().min(1).max(500) })

export default defineEventHandler(async (event) => {
  const session = await useSession<{ userId?: string }>(event)
  if (!session.data.userId) {
    throw createError({ status: 401, statusText: 'Sign in to add notes' })
  }

  const note = await readValidatedBody(event, NewNote)
  event.res.headers.set('cache-control', 'no-store')
  return { id: crypto.randomUUID(), text: note.text, author: session.data.userId }
})

readValidatedBody takes any Standard Schema library (Zod, Valibot, ArkType) or a plain function, and rejects invalid input with a 400 whose data.issues lists what failed. Under the default server builder these helpers are still implemented with Nitro v2 and h3 v1, but your file imports from neither. The release post says the same handler will run unchanged on Nuxt 5, which moves to Nitro v3 and h3 v2.

There is one trap: the auto-imported defineEventHandler, getQuery, readBody and friends in server/ are still h3's own helpers, which take a different event. So import from nuxt/server explicitly, defineEventHandler included. If a nuxt/server helper such as useSession receives h3's event, the request fails with a 500 whose message starts with [NUXT_E8012] and names the import to change.

A few helpers also behave differently from their h3 v1 namesakes:

  • createError takes status and statusText, not statusCode and statusMessage.
  • sendRedirect returns the response body instead of sending it, so write return sendRedirect(event, '/login').
  • Response headers go through event.res.headers instead of setResponseHeader and its relatives.

The upgrade guide's Moving to nuxt/server section has the full table.

Nuxt now has a root application secret, runtimeConfig.appSecret, set with the NUXT_APP_SECRET environment variable. Features and modules derive their own secrets from it, so it is the only secret you configure. Generate one with at least 32 random bytes:

openssl rand -base64 32

In development Nuxt generates and keeps one for you if none is set. Builds never generate one: without the variable, useSession fails in production with a 500 that names NUXT_APP_SECRET.

The first feature built on it is a set of session helpers. The session data is encrypted and signed ("sealed") into a cookie with the iron library, so there is no server-side storage to set up. Here is the sign-in route that the notes handler above relies on. It stores the name it receives as userId for a week:

server/api/login.post.ts
import { defineEventHandler, readValidatedBody, useSession } from 'nuxt/server'
import { z } from 'zod'

export default defineEventHandler(async (event) => {
  const { name } = await readValidatedBody(event, z.object({ name: z.string().min(1) }))
  const session = await useSession<{ userId?: string }>(event, { maxAge: 60 * 60 * 24 * 7 })
  await session.update({ userId: name })
  return { signedIn: true }
})

The cookie is named nuxt-session and defaults to httpOnly, secure, sameSite: 'lax' and path: '/'. maxAge is in seconds and expires both the cookie and the sealed value; session.clear() signs the user out. Two limits from the server imports guide:

  • A session that serialises to more than 4096 bytes throws, so store an id and keep the rest in a database.
  • h3's own useSession, which the auto-import still resolves to, is a different implementation: a cookie written by one cannot be read by the other.

Typed $fetch that scales to large apps

$fetch and useFetch have long inferred response types from your server routes. Past a few hundred routes, that inference hit TypeScript's limit with error TS2589: Type instantiation is excessively deep and possibly infinite. Nuxt 4.6 rebuilds it on a compiled route tree. The release post's numbers: at 1,000 routes, type checking drops from 12.8 million instantiations and 7.44 seconds to about 51,000 and 0.2 seconds, and peak memory in their runs falls from 946 MB to 140 MB.

The new types also check the body, query and headers a handler validates, so a missing or wrongly typed body is a compile error at the call site. On Nuxt 4 it is opt-in:

nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    routeTypedFetch: true,
  },
})

It is opt-in because inference changes slightly. A Date returned by a handler is typed as a string at the call site, which is what JSON actually delivers, and hand-written ServerRoutes augmentations need rewriting. A companion option, experimental.strictRouteTypes, rejects calls to paths that do not exist instead of typing them as unknown.

One catch on Nuxt 4: Nitro still declares a global $fetch with the old types, so import $fetch from #build/fetch where you want a direct call checked. useFetch gets the new types either way.

Opt components into Vapor Mode

Nuxt 4.6 supports Vapor Mode, Vue 3.6's new way of compiling components without the virtual DOM, in interop mode. The app root stays on the virtual DOM, and you opt single components or pages in with the vapor attribute:

nuxt.config.ts
export default defineNuxtConfig({
  vue: {
    vapor: true,
  },
})

Then write <script setup vapor lang="ts"> in the component you want compiled that way. The release post says routing, useAsyncData, layouts and most built-in components work unchanged, and that Nuxt added a Vapor test suite to track what is supported. The Nuxt docs list the known limitations, such as no Options API inside Vapor components.

Clearer errors and Nuxt CLI v4 in development

Server errors in development now come with source-mapped stack traces and a code frame from your own file. The old Youch overlay is replaced by my-bad, which renders inside your app's error page and offers a "Copy error" button, including a format meant to paste into an AI assistant.

Nuxt CLI v4 comes as a dependency of nuxt, so you get it with the upgrade. Most of what changes is in nuxt dev:

  • A panel pinned to the bottom of the terminal has single-key shortcuts: r restarts, o opens the browser, l shows logs, n requests, p pages and server routes. Pass --no-tui for the classic output.
  • nuxt dev says why it restarted, and a save that changes nothing no longer restarts at all.
  • New commands nuxt curl and nuxt task talk to the running dev server, and nuxt docs "<query>" searches the docs for your installed version.

The release post measures the install at 3.5 MB instead of 13.1 MB and the first paint in nuxt dev at 50 ms instead of 330 ms. nuxt init is gone in favour of npm create nuxt@latest.

Lighter payloads, fetch addons and redrawn pages

Three smaller changes:

  • serialize: false on useFetch and useAsyncData keeps the data out of the payload (what the server sends to the browser with the page), which is mostly useful in components that never hydrate.
  • createUseFetch accepts addons, written with defineUseFetchAddon, for behaviour like refresh on focus or retries.
  • The built-in loading screen, 404 page and error page were redrawn, and the 404 page has a back button.

How to upgrade

  1. Check Node. Nuxt 4.6 requires Node ^22.22.3, ^24.15.0 or >=26.0.0, a higher floor than 4.5's ^22.19.0 and ^24.11.0. Update your CI image and your hosting platform's Node setting before you merge: on an older Node, npm only prints an engine warning unless engine-strict is set, so the mismatch is easy to miss.
  2. If you plan to use sessions, set NUXT_APP_SECRET on every environment.
  3. Run the command the release post recommends, which also refreshes your lockfile and pulls in Nuxt CLI v4:
npx nuxt upgrade --dedupe

After that, nothing else is required. If you are still on Nuxt 3, there is no 3.x release this time: Nuxt 3 reached end of life on 31 July.

The release post gives no date for Nuxt 5, but says most of what is new in it is already in 4.6. To turn on those Nuxt 5 defaults together, typed fetch and typed pages among them, set future.compatibilityVersion: 5 in nuxt.config.ts.

Check it stuck3 questions from the Server routes quiz.
Practice now
Was this helpful?

Keep reading