Routing
TL;DR
Nuxt generates vue-router configuration from your pages/ directory structure. No manual route definitions. Dynamic segments use brackets ([id]), catch-alls use spread ([...slug]), optional params use double brackets ([[id]]). Navigate with <NuxtLink> (template) or navigateTo() (programmatic). Route params are always strings β parse them yourself.
Mental Model
File system = route table. Nuxt scans pages/ at build time and generates the equivalent vue-router config. You never write createRouter({ routes: [...] }). Every .vue file in pages/ becomes a route. Folders create path segments. The bracket syntax creates dynamic segments.
pages/
βββ index.vue β /
βββ about.vue β /about
βββ posts/
β βββ index.vue β /posts
β βββ [id].vue β /posts/:id
βββ [...slug].vue β catch-all (404)
Important: If the pages/ directory doesnβt exist, Nuxt doesnβt generate a router at all. Your app.vue renders directly without routing. The moment you create pages/, Nuxt activates the router and app.vue must include <NuxtPage /> as the route outlet.
File-Based Routing Mechanics
The mapping rules:
| File path | Route | Notes |
|---|---|---|
pages/index.vue | / | Index routes use the file name index.vue |
pages/about.vue | /about | File name becomes path segment |
pages/users/index.vue | /users | Folder + index = folder path |
pages/users/profile.vue | /users/profile | Nested static segment |
pages/blog/2024/recap.vue | /blog/2024/recap | Deep nesting works |
The router is generated in .nuxt/routes.mjs β you can inspect it to verify your understanding.
Dynamic Params
Square brackets create dynamic segments:
pages/
βββ users/
β βββ [id].vue β /users/:id (required)
β βββ [[id]].vue β /users/:id? (optional)
βββ posts/
β βββ [slug].vue β /posts/:slug
βββ docs/
βββ [...slug].vue β /docs/* (catch-all, slug is an array)
[id].vue β Required dynamic param. /users/123 matches, /users/ does not.
[[id]].vue β Optional param. Both /users/ and /users/123 match. Use this when a page can render with or without the param (e.g., a filter that defaults to βallβ).
[...slug].vue β Catch-all. Captures the entire remaining path as an array. /docs/getting-started/intro β params.slug = ['getting-started', 'intro'].
Access params in the component:
<script setup lang="ts">
const route = useRoute()
// For [id].vue at /users/42
const userId = route.params.id // "42" (always a string!)
// For [...slug].vue at /docs/a/b/c
const parts = route.params.slug // ["a", "b", "c"]
</script>
Params are always strings. /users/42 gives you "42", not 42. Parse it: Number(route.params.id) or validate it (see Route Validation below).
Nested Routes
Nested routes require two things:
- A parent
.vuefile with<NuxtPage />inside it - A same-named folder containing child routes
pages/
βββ users.vue β Parent layout (contains <NuxtPage />)
βββ users/
βββ index.vue β /users (rendered inside users.vue)
βββ [id].vue β /users/:id (rendered inside users.vue)
The parent users.vue acts as a persistent wrapper β it stays mounted while children swap:
<!-- pages/users.vue -->
<template>
<div>
<h1>Users Section</h1>
<nav>
<NuxtLink to="/users">All Users</NuxtLink>
</nav>
<!-- Child routes render here -->
<NuxtPage />
</div>
</template>
This is how you build tabbed interfaces, master-detail views, or any UI where a parent section wraps multiple sub-pages.
Navigation
<NuxtLink> β the default choice for navigation:
<template>
<nav>
<!-- Static route -->
<NuxtLink to="/about">About</NuxtLink>
<!-- Dynamic route -->
<NuxtLink :to="`/users/${user.id}`">{{ user.name }}</NuxtLink>
<!-- Named route (if you prefer) -->
<NuxtLink :to="{ name: 'users-id', params: { id: user.id } }">
{{ user.name }}
</NuxtLink>
<!-- External link (renders as <a> with target) -->
<NuxtLink to="https://nuxt.com" external>Docs</NuxtLink>
<!-- Disable prefetching for performance -->
<NuxtLink to="/heavy-page" :prefetch="false">Heavy</NuxtLink>
</nav>
</template>
<NuxtLink> gives you:
- Prefetching β when the link enters the viewport, Nuxt prefetches the target pageβs JS chunk. Instant navigation.
- Active classes β
.router-link-activeand.router-link-exact-activeapplied automatically. Style them in CSS. - Client-side navigation β no full-page reload. Only the page component swaps.
navigateTo() β programmatic navigation:
<script setup lang="ts">
const goToUser = (id: number) => {
navigateTo(`/users/${id}`)
}
// With options
const goToLogin = () => {
navigateTo('/login', { redirectCode: 301, replace: true })
}
// External URL
const goToExternal = () => {
navigateTo('https://example.com', { external: true })
}
</script>
useRoute() β read current route state:
<script setup lang="ts">
const route = useRoute()
route.params // Dynamic params
route.query // Query string (?page=2 β { page: '2' })
route.path // Current path without query
route.fullPath // Path + query + hash
route.name // Route name (generated from file path)
route.meta // Page meta defined in definePageMeta
</script>
useRouter() β router instance (rarely needed directly):
<script setup lang="ts">
const router = useRouter()
router.back()
router.forward()
router.push('/somewhere') // Prefer navigateTo() β it works in SSR too
</script>
Prefer navigateTo() over router.push() because navigateTo handles SSR correctly (returns a redirect response on server) while router.push() only works client-side.
Route Validation
Use definePageMeta with a validate function to guard a route before it renders:
<!-- pages/users/[id].vue -->
<script setup lang="ts">
definePageMeta({
validate: (route) => {
// Must be a numeric ID
return /^\d+$/.test(route.params.id as string)
},
})
const route = useRoute()
const userId = Number(route.params.id)
</script>
If validate returns false, Nuxt shows the 404 error page. You can also return an object to customize the error:
definePageMeta({
validate: (route) => {
const id = Number(route.params.id)
if (isNaN(id) || id < 1) {
return createError({
statusCode: 400,
statusMessage: 'Invalid user ID',
})
}
return true
},
})
Validation runs before the page component is created β itβs the earliest point to reject bad params.
definePageMeta
Beyond validation, definePageMeta configures per-page behavior:
<script setup lang="ts">
definePageMeta({
// Which layout to use
layout: 'admin',
// Route middleware to apply
middleware: ['auth', 'admin-only'],
// Custom meta data (accessible via route.meta)
title: 'User Profile',
// Keep-alive (preserve component state on navigation)
keepalive: true,
// Page transition
pageTransition: { name: 'slide-left' },
// Validation function
validate: (route) => /^\d+$/.test(route.params.id as string),
})
</script>
definePageMeta is a compiler macro β itβs extracted at build time. You canβt use runtime variables in it. Only static values or imported constants.
Walkthrough
Open the 02-routing starter:
cd starters/02-routing
npm install
npm run dev
- Explore the pages. Check
pages/directory β note how file names map to routes. - Test dynamic params. Navigate to
/posts/1,/posts/helloβ checkuseRoute().params.idin each. - Add a catch-all. Create
pages/[...slug].vuethat shows a custom 404 message with the attempted path. - Test NuxtLink prefetching. Open Network tab, hover over a
<NuxtLink>β watch the JS chunk prefetch fire. - Add validation. On
pages/posts/[id].vue, adddefinePageMeta({ validate })that rejects non-numeric IDs. Navigate to/posts/abcβ verify 404.
Gotchas
- Missing
pages/= no router. If you only haveapp.vuewithout apages/directory,<NuxtPage>does nothing anduseRoute()isnβt available. This is intentional β some apps donβt need routing. - Route params are always strings.
/users/42βparams.id === "42"not42. Always parse. Always validate. navigateToreturns a promise. In middleware, you mustreturn navigateTo('/login')β not just call it. Without the return, the middleware continues executing.- Prefetching on NuxtLink is aggressive. In a list of 100 items, each
<NuxtLink>prefetches when it enters the viewport. For long lists, add:prefetch="false"or use<NuxtLink prefetch-on="interaction">to only prefetch on hover/focus. - Nested routes need BOTH parts. A
pages/users/folder without apages/users.vueparent just creates flat routes under/users/. The nesting behavior (persistent parent wrapper) only activates when both exist. - Route names are auto-generated.
pages/users/[id].vueβ route nameusers-id. The pattern is path segments joined by hyphens with brackets removed. Use DevTools to see actual names. definePageMetais a compiler macro. You cannot useref(),computed(), or any runtime value inside it. It runs at build time. If you need dynamic meta, use middleware oruseHead()instead.
Exercise
Build a mini blog structure:
- Create pages:
/postsβ list page/posts/[slug]β single post (validate: slug must be lowercase-with-dashes only)/posts/[slug]/commentsβ nested under single post (addposts/[slug].vueas parent with<NuxtPage />)[...slug].vueβ catch-all 404
- Add navigation between pages using
<NuxtLink>. - On the catch-all page, display the attempted path from
params.slug. - Add programmatic navigation: a button that calls
navigateTo('/posts')after a 2-second delay (simulating a redirect after form submission).