You want /account to bounce signed-out visitors to the login page, /checkout to refuse an empty cart, and a maintenance switch that covers every page. In Nuxt 4 all three are route middleware: a function that runs before the router finishes a navigation and either lets it through, sends the visitor somewhere else, or stops it.
That is its one job. It cannot keep data private: the API behind /account still has to check the session itself. The examples use the Nuxt 4 layout: pages in app/pages/, middleware in app/middleware/, and server/ still at the project root.
What route middleware is
A route middleware is a function Nuxt calls with two arguments: to, the route the visitor is going to, and from, the route they are leaving. It runs in the Vue part of the app, on the server during the first render and in the browser on every client-side navigation. It is not the same thing as server middleware in server/middleware/, which runs inside Nitro for every HTTP request and never sees a Vue route.
A Vue Router guard can take a third argument, a next() callback. Nuxt middleware gets none. You return a value, or nothing to let the navigation carry on.
Anonymous middleware for one page
The quickest kind lives inside the page that needs it, in definePageMeta(). It suits a rule that only one page cares about.
<script setup lang="ts">
definePageMeta({
middleware: (to) => {
if (!to.query.cart) {
return navigateTo('/cart')
}
},
})
</script>
<template>
<h1>Checkout</h1>
</template>
Here a cart query parameter stands in for the shop's real cart state.
definePageMeta is a compiler macro, so Nuxt extracts it at build time and knows the page's middleware before the component's code is downloaded. The price is that the macro cannot use the component's own values, such as a ref declared in <script setup>. It can use imports and pure functions defined in the same file.
Named middleware shared by several pages
Put a file in app/middleware/ and its name becomes the middleware's name. auth.ts is auth; a file called requireAdmin.ts would be normalised to require-admin. The file is loaded only when a page lists it.
export default defineNuxtRouteMiddleware((to) => {
const session = useCookie('session')
if (!session.value) {
return navigateTo({
path: '/login',
query: { redirect: to.fullPath },
})
}
})
useCookie() works inside middleware because middleware runs with the Nuxt app context. On the server it reads the cookie from the incoming request; in the browser it reads document.cookie. Passing the original path as a redirect query lets the login page send the visitor back afterwards.
A page opts in by name:
<script setup lang="ts">
definePageMeta({
middleware: ['auth'],
})
</script>
<template>
<h1>Your account</h1>
</template>
Global middleware for every route
Add .global before the extension and the file runs on every route change, with no page having to list it. Use it for things that are true of the whole site.
export default defineNuxtRouteMiddleware((to) => {
const config = useRuntimeConfig()
if (config.public.maintenance && to.path !== '/maintenance') {
return navigateTo('/maintenance')
}
})
This reads a public runtime config value, so it needs runtimeConfig: { public: { maintenance: false } } in nuxt.config.ts, and you flip it at deploy time with the environment variable NUXT_PUBLIC_MAINTENANCE=true. Without the to.path check the redirect would loop, because arriving at /maintenance is also a route change.
The order middleware runs in
Global middleware runs first, then the page's list in the order written. Global files run alphabetically by file name, and file names sort as strings, so 10.x.global.ts comes before 2.x.global.ts. The numeric prefix in 01.maintenance.global.ts is how you pin the order: pad with a zero and leave gaps for files you add later.
For a page with middleware: ['auth', 'admin'], the full sequence is every .global.ts file in name order, then auth, then admin. The first one that returns a redirect or an abort stops the chain, so put the cheap checks first. There is no point running an admin check for a visitor who is not even signed in.
Redirect with navigateTo, stop with abortNavigation
Nuxt has two helpers for this, and the docs recommend them over the other return values Vue Router accepts.
navigateTo() redirects. It takes a path or a route object, and two of its options matter in middleware:
redirectCodesets the status of the redirect the server sends for a first request: 302 by default, 301 for a permanent move.external: trueallows a target on another site. Without it, Nuxt refuses an external URL with an error.
abortNavigation() stops the navigation. In the browser the visitor stays on the current page. On the first, server-rendered request there is no such page, so Nuxt shows a 404 error page.
Pass it an error to show the error page with that error. On the server that always works, but in a client-side navigation Nuxt shows the page only when the error has fatal: true. Without that flag the visitor stays where they are, with nothing on screen to say why. This middleware, for a page such as app/pages/posts/[slug].vue, sets the flag:
export default defineNuxtRouteMiddleware((to) => {
const slug = String(to.params.slug ?? '')
if (!/^[a-z0-9-]+$/.test(slug)) {
return abortNavigation(
createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true }),
)
}
})
Middleware on the server and in the browser
For a server-rendered page, middleware for the first request runs twice: once on the server while rendering, and once more in the browser while Nuxt hydrates, the step where Vue takes over the server's HTML. After that, every client-side navigation runs it once, in the browser only.
Most middleware does not care. When it does, Nuxt gives you three checks. import.meta.server is true only in the server build and import.meta.client only in the browser build, and the bundler removes the dead branch. To skip only the repeat during hydration, check the app's state, as this version of the auth middleware does:
export default defineNuxtRouteMiddleware((to) => {
const nuxtApp = useNuxtApp()
// The server already ran this check for the current request
if (import.meta.client && nuxtApp.isHydrating && nuxtApp.payload.serverRendered) {
return
}
const session = useCookie('session')
if (!session.value) {
return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
}
})
The serverRendered part matters where ssr: false turns server rendering off: there the browser's run is the only one, so it must not be skipped.
The browser run also changes what useCookie() can see. A session cookie should be httpOnly, which means the browser never shows it to JavaScript. Then useCookie('session') sees it on the server but reads undefined in the browser, and a client-side navigation to /account would bounce a signed-in visitor.
A simple answer is a second, readable cookie that only says "signed in", set and cleared alongside the real one. Point the middleware's useCookie() at that marker instead of session. The server never trusts the marker; it checks the real cookie.
Protect data in the server route, not the middleware
Route middleware decides what the visitor sees. After the first page it runs in a browser the visitor controls, and it only runs when Nuxt navigates. Anyone can open the browser's developer tools and call fetch('/api/orders') directly, and no middleware runs for that request. If the server route hands over the data, the data is public.
export default defineEventHandler(() => {
// The auth middleware already checked the cookie, so this must be safe
return [{ id: 41, total: 32 }, { id: 42, total: 18 }]
})
Every server route that returns something private has to check the cookie itself, and so does every route that changes data.
export default defineEventHandler((event) => {
const session = getCookie(event, 'session')
if (!session) {
throw createError({ statusCode: 401, statusMessage: 'Sign in first' })
}
// Look up the orders that belong to this session; a fixed list stands in here
return [{ id: 41, total: 32 }, { id: 42, total: 18 }]
})
Here getCookie() reads the real session cookie from the request, httpOnly or not, because the server sees every header. The lookup from session to user belongs in a server utility you call from each protected route, and the cookie should hold a signed or sealed value, never a plain user id.
With that in place, the route middleware becomes what it should be: a courtesy that saves a signed-out visitor a round trip to a page they could not use anyway.