Nuxt route middleware is a function that runs before Nuxt navigates to a page, and decides whether the navigation goes ahead, goes somewhere else, or stops. It's the standard place for "you must be signed in to see this". This post covers the three kinds (named, global and inline), builds an auth guard that sends users back where they came from, adds a role check that asks the server, and ends with the mistakes that make middleware slow or insecure. Nuxt facts are from the Nuxt 4 docs on nuxt.com.
Nuxt has two things called middleware, and mixing them up is the first mistake:
| Route middleware | Server middleware | |
|---|---|---|
| Lives in | app/middleware/ | server/middleware/ |
| Part of | The Vue app (it wraps vue-router navigation) | The Nitro server |
| Runs | Before navigating to a page, on the server for the first page load and in the browser after that | On every HTTP request to the server, API calls included |
| Can | Redirect, cancel the navigation, show an error page | Read and add to the request context |
Route middleware decides which pages a user can see. It does nothing for your API: a request to /api/orders never passes through it. Keep that in mind until the "Protect the API too" section.
There are three kinds of route middleware:
app/middleware/, used by pages that ask for it by name. auth.ts becomes auth; isAdmin.ts becomes is-admin (names are kebab-cased).app/middleware/ with a .global suffix, such as analytics.global.ts. It runs on every route change.definePageMeta.Here's an auth guard. It uses useUserSession() from nuxt-auth-utils, the module this site runs on; any composable that tells you whether the user is signed in works the same way.
// app/middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
const { loggedIn } = useUserSession()
if (!loggedIn.value) {
return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
}
})A page opts in with definePageMeta:
<!-- app/pages/dashboard/index.vue -->
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
</script>The function receives to (where the user is going) and from (where they are). There's no next() like in vue-router guards. What you return is the decision:
| Return | Effect |
|---|---|
| nothing | The navigation continues. |
navigateTo(path) | Redirects. On the server it's a 302 unless you pass redirectCode. |
abortNavigation() | Stops the navigation. Pass a string or an Error to show the error page. |
Always return the result of navigateTo. The Nuxt docs are explicit about it: calling navigateTo() in middleware without returning it breaks the middleware flow.
The guard put the original path in ?redirect=. After a successful login, read it back, but don't trust it:
// app/utils/safe-redirect.ts
export function safeRedirect(value: unknown, fallback = '/dashboard') {
return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//')
? value
: fallback
}// in the login page, after the session is set
const route = useRoute()
await navigateTo(safeRedirect(route.query.redirect))Anyone can craft a login link with any redirect value. navigateTo refuses external URLs unless you pass external: true (it throws), but a crash on the login page is a poor result too. Accepting only paths that start with a single / keeps the user on your site and falls back to the dashboard for anything else.
A page can list more than one:
definePageMeta({ middleware: ['auth', 'admin'] })They run in array order, after all global middleware. If auth redirects, admin never runs, so admin can assume a signed-in user.
Global middleware runs before every navigation, sorted alphabetically by file name. When order matters, prefix the names: 01.setup.global.ts, 02.auth.global.ts. Zero-pad the numbers, because the names are sorted as strings and 10. would sort before 2..
A global auth guard flips the default: every page is protected unless it says otherwise. That's safer for an app that is mostly behind a login, because a new page can't be public by accident. Mark public pages with custom page meta:
// app/middleware/auth.global.ts
export default defineNuxtRouteMiddleware((to) => {
if (to.meta.public) return
const { loggedIn } = useUserSession()
if (!loggedIn.value) {
return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
}
})// app/pages/login.vue, in <script setup>
definePageMeta({ public: true })// app/types/page-meta.d.ts
declare module '#app' {
interface PageMeta {
public?: boolean
}
}
export {}The type augmentation makes public a known key on both definePageMeta and to.meta. The export {} matters: the Nuxt docs point out that an augmentation file must import or export something.
Notice the to.meta.public check comes first. Without it, the login page would redirect to the login page forever. Any middleware that redirects has to make sure it can't redirect to a page it also guards.
For a rule that only one page has, write the function in definePageMeta:
<script setup lang="ts">
definePageMeta({
middleware: [
function (to) {
if (!/^\d+$/.test(String(to.params.id))) {
return abortNavigation(createError({ statusCode: 404, statusMessage: 'Order not found' }))
}
},
],
})
</script>definePageMeta is a compiler macro: Nuxt hoists its argument out of the component. The function can use imports and auto-imported helpers, but not variables from the page's <script setup>. If the logic needs more than a few lines, make it a named middleware.
Sometimes the client doesn't know enough, for example whether the user is an admin. This site's admin guard asks its own API:
// app/middleware/admin.ts
export default defineNuxtRouteMiddleware(async () => {
const { isAdmin } = await useRequestFetch()('/api/access')
if (!isAdmin) {
return abortNavigation(createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true }))
}
})Two details:
useRequestFetch(), not $fetch. On the first page load this middleware runs on the server, and a plain $fetch from there doesn't forward the user's cookies, so the API would see an anonymous request. useRequestFetch() forwards the request headers on the server and behaves like $fetch in the browser.With server rendering, the middleware for the first page runs twice: once on the server to render the page, and again in the browser when the app hydrates. After that, every client-side navigation runs it in the browser only.
If a check is expensive and the server already did it, skip the repeat during hydration, as the Nuxt docs show:
export default defineNuxtRouteMiddleware(() => {
const nuxtApp = useNuxtApp()
if (import.meta.client && nuxtApp.isHydrating && nuxtApp.payload.serverRendered) return
// the expensive check
})Use import.meta.server or import.meta.client the same way when a check only makes sense on one side.
useRoute(). It isn't reliable inside middleware, and Nuxt warns when you call it there, even through a composable. Use to and from, and pass the route into any helper that needs it.useFetch or useAsyncData, which can show a loading state.return. navigateTo() without return lets the navigation continue to the page you meant to block.to.path (or a page meta flag) before redirecting to a page the same middleware runs on.Every page your middleware guards loads its data from somewhere, usually your own server routes. Those need their own check, because anyone can call /api/orders with curl. With nuxt-auth-utils that's one line at the top of the handler:
// server/api/orders.get.ts
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
return listOrdersFor(user.id)
})requireUserSession throws a 401 when there's no session. The Nuxt server routes guide shows how the rest of such a handler is built: validation, status codes and errors. Route middleware for the pages, a session check in every API handler: you need both.
What's the difference between navigateTo and abortNavigation? navigateTo sends the user somewhere else. abortNavigation stops the navigation where it is, or shows the error page if you pass an error.
Can route middleware be async? Yes. Nuxt waits for it before navigating, which is why slow middleware makes the whole app feel slow.
How do I apply middleware to a whole folder of pages? Use a global middleware that checks to.path, or set it in the pages:extend hook in nuxt.config. Note that the hook overrides middleware set in definePageMeta on the same page.
Can I add middleware from a plugin? Yes, with addRouteMiddleware(name, fn, { global: true }). A named middleware added this way replaces a file-based one with the same name.
The guard decides who gets in. If what's behind it is an admin panel, the QuasarUI Templates are four complete Quasar admin dashboards with light and dark mode, each with dashboard, reports, task management and kanban pages, delivered as a private GitHub repo. Open the live previews to see the screens before you build your own.