Table of contents [Show]
- Why routing matters in Single Page Applications (SPAs)
- Quick setup: a minimal Vue 3 + Vue Router project
- Dynamic route matching (route params)
- Nested routes and <router-view>
- Passing props to route components (recommended)
- Programmatic navigation (router.push / replace / go)
- Lazy-loading route components (code-splitting)
- Route matching syntax: catch-all and repeatable params
- Navigation guards and authentication flows
- Dynamic routing: add & remove routes at runtime
- Practical step-by-step project: User dashboard with nested & dynamic routes
- Advanced patterns & tips
- Route troubleshooting checklist (common gotchas)
- Bonus: SEO & deep links
- Best practices & real-world recommendations
- Full minimal example: router/index.js + main.js
- Common mistakes & how I avoid them (real-world tips)
- Where to read next (official docs & resources)
- Final checklist before you ship routing features
Why routing matters in Single Page Applications (SPAs)
Routing is how you wire URLs to user interfaces in SPAs. Good routing makes your app feel like a native app: links work, the browser back/forward buttons behave, deep links load the right content, and your components only render when needed. Vue Router is the official routing library for Vue and integrates tightly with Vue’s component model and reactivity. We'll use Vue Router 4 examples (for Vue 3).
Before we begin, make sure you have Node.js and npm/yarn installed, and basic familiarity with Vue 3.
Quick setup: a minimal Vue 3 + Vue Router project
Step 1 — scaffold with Vite
Open a terminal and run (choose npm/yarn/pnpm as you prefer):
npm create vite@latest vue-router-demo --template vue
cd vue-router-demo
npm install
Step 2 — install Vue Router
npm install vue-router@4Step 3 — basic router file
Create src/router/index.js (or index.ts if using TypeScript) and wire a couple of simple routes:
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import About from '../views/About.vue'
const routes = [
{ path: '/', name: 'home', component: Home },
{ path: '/about', name: 'about', component: About }
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default routerStep 4 — install router into app
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')Step 5 — use RouterLink and RouterView
<!-- src/App.vue -->
<template>
<nav>
<RouterLink to="/">Home</RouterLink> |
<RouterLink to="/about">About</RouterLink>
</nav>
<main>
<RouterView />
</main>
</template>Now start the dev server and verify basic navigation works:
npm run devDynamic route matching (route params)
What are dynamic routes?
Dynamic routes let a single route record handle many similar paths by using route parameters (params). These are declared with a leading colon in the path (e.g., /users/:id), and the matched values are available in the route object as route.params. This is the backbone of user profiles, blog posts, product pages, and any resource that changes by id or slug.
Example:
// router
{ path: '/users/:id', name: 'user', component: UserDetails }
// inside UserDetails.vue
<template>
<div>
<h2>User: {{ id }}</h2>
</div>
</template>
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
const id = computed(() => route.params.id)
</script>When you visit /users/42, route.params.id will be "42". One important note: Vue Router may reuse the same component instance when params change between navigations to the same route record, so you should react to param changes (watch them) to reload data. :contentReference[oaicite:0]{index=0}
Reacting to param changes
If a user clicks from /users/1 to /users/2 and the same component instance remains mounted, lifecycle hooks like mounted won't re-fire. Use a watcher:
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const user = ref(null)
async function fetchUser(id) {
// replace with real fetch
user.value = await fakeApiFetchUser(id)
}
// initial fetch
fetchUser(route.params.id)
// react to param changes
watch(() => route.params.id, id => {
fetchUser(id)
})
</script>Named params, multiple params, and best practices
You can define multiple params like /users/:username/posts/:postId. Use meaningful param names (id vs slug) and favor named routes for programmatic navigation (more stable when paths change).
Nested routes and <router-view>
Why nested routes?
Nested routes map segments of a URL to nested UI structure. If a parent component contains a <router-view>, its children routes render inside it. This lets you compose layouts naturally — for example, a User page that has nested Profile and Posts views.
Example route config with children:
const routes = [
{
path: '/users/:id',
component: UserLayout,
children: [
{ path: '', component: UserHome }, // /users/:id
{ path: 'profile', component: UserProfile }, // /users/:id/profile
{ path: 'posts', component: UserPosts } // /users/:id/posts
]
}
]Inside UserLayout.vue, include a nested <router-view> where child views will render:
<template>
<div>
<h1>User Layout</h1>
<router-view /> <!-- child route renders here -->
</div>
</template>Nested routes let you share layout/UI between child pages without repeating markup. Note that nested paths that start with a / are treated as root paths — use relative child paths for nesting. :contentReference[oaicite:1]{index=1}
Empty path child to render a "default" child
If you want something to render at /users/:id (no extra segment), use an empty string child path:
{ path: '', component: UserOverview }Named views (multiple router-views)
Sometimes you want different components to render into multiple slots of the page at the same time (for example: sidebar + main). Named views let you map route records to named <router-view name="sidebar"> and the default <router-view>. Example:
// router
{ path: '/compose', components: { default: ComposeMain, sidebar: ComposeTools } }
// App.vue
<router-view name="sidebar" />
<router-view /> Passing props to route components (recommended)
Why use props?
Passing route params as props keeps components decoupled from the router, simplifying unit testing and making code clearer.
Methods for passing props
- props: true — passes
route.paramsas props - props: { } — static props
- props: (route) => ({ ... }) — function, useful for transforming params or including query values
const routes = [
{
path: '/users/:id',
component: UserDetails,
props: true // component receives `id` as a prop
},
{
path: '/search',
component: SearchResults,
props: route => ({ query: route.query.q || '' })
}
]In UserDetails.vue you can now declare props: ['id'] (Options API) or accept props in <script setup> (Composition API):
<script setup>
defineProps(['id'])
// use `id` directly — easier for testing and composition
</script>You programmatically navigate when actions trigger navigation (e.g., after a form submission, or when handling a conditional redirect). Vue Router exposes navigation methods such as router.push(), router.replace(), and router.go(). Note that these return Promises (so you can await navigation completion or detect navigation failures). :contentReference[oaicite:2]{index=2}
Example using Composition API
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
function goToUser(id) {
// named route push
router.push({ name: 'user', params: { id } })
// or a string path
// router.push(`/users/${id}`)
}
</script>Replace vs Push
router.replace() navigates without adding a new history entry (no back button to previous route). Useful for login redirects or form steps where you don't want history bloat. You can also pass replace: true in a <router-link>.
Because navigation methods return Promises, you can await them:
await router.push('/dashboard')
// now do something once navigation finished, e.g. close mobile menuLazy-loading route components (code-splitting)
Large apps benefit from splitting route components into separate chunks so the browser only loads what's needed. Vue Router supports dynamic imports for route-level code splitting. Use arrow functions that return import(). :contentReference[oaicite:3]{index=3}
const routes = [
{ path: '/', component: () => import('../views/Home.vue') },
{ path: '/users/:id', component: () => import('../views/UserDetails.vue') }
]Bundlers (Vite / Webpack) create separate files for those imports — faster initial loads and better performance.
Route matching syntax: catch-all and repeatable params
Vue Router supports repeatable params to match multi-segment paths using + (one or more) and * (zero or more). This is handy for document paths, nested slugs, or catch-all behavior. For example, /:chapters+ will match /a, /a/b, /a/b/c etc. Use these carefully and prefer named routes in programmatic navigation (they're less error-prone). :contentReference[oaicite:4]{index=4}
// matches /one or /one/two or /one/two/three
{ path: '/:chapters+', name: 'chapters', component: Chapters }When using named routes with repeatable params you must pass an array for those params when resolving: router.push({ name: 'chapters', params: { chapters: ['a','b'] } }).
Types of guards
Guards let you run logic before, during, or after navigation. There are:
- Global guards —
router.beforeEach,router.afterEach - Per-route
beforeEnter - In-component guards —
beforeRouteEnter,beforeRouteUpdate,beforeRouteLeave
Example: auth guard
// router/index.js
router.beforeEach((to, from, next) => {
const requiresAuth = to.meta.requiresAuth
const loggedIn = Boolean(localStorage.getItem('token'))
if (requiresAuth && !loggedIn) {
next({ name: 'login', query: { redirect: to.fullPath } })
} else {
next()
}
})You can put meta: { requiresAuth: true } on route records and check it in a global guard — this pattern is common for protecting pages.
Note: when using nested routes, both parent and child records may have guards; a parent guard won't run when navigating between siblings. If you need per-child behavior, add guards directly on the child. :contentReference[oaicite:5]{index=5}
Dynamic routing: add & remove routes at runtime
For advanced apps that load modules on demand (admin consoles, plugin systems, or feature flags), Vue Router lets you add and remove routes at runtime via router.addRoute() and router.removeRoute(). This is useful when you want routes to reflect user roles or lazy-loaded modules. :contentReference[oaicite:6]{index=6}
Adding a top-level route
// add a new top-level route
router.addRoute({ path: '/beta', name: 'beta', component: () => import('./views/Beta.vue') })Adding a child route to an existing named parent
// assumes there is a route named 'admin'
router.addRoute('admin', { path: 'reports', component: AdminReports })Removing a route
router.removeRoute('beta')When a route is removed, its aliases and children are also removed. You can also capture the callback returned by addRoute() and call it to remove the added route.
Practical step-by-step project: User dashboard with nested & dynamic routes
Let’s build a small concrete example so you can see all the concepts in action. We’ll create a user dashboard with:
- /users — list of users
- /users/:id — user layout (nested)
- /users/:id/profile — nested child
- /users/:id/posts — nested child (lazy-loaded)
- programmatic navigation and breadcrumbs using
route.matched
Step A — router configuration (src/router/index.js)
import { createRouter, createWebHistory } from 'vue-router'
import Users from '../views/Users.vue'
import UserLayout from '../views/UserLayout.vue'
import UserProfile from '../views/UserProfile.vue'
// lazy-load posts
const UserPosts = () => import('../views/UserPosts.vue')
const routes = [
{ path: '/users', name: 'users', component: Users },
{
path: '/users/:id',
name: 'user',
component: UserLayout,
props: true,
children: [
{ path: '', name: 'user.home', component: UserProfile }, // default
{ path: 'profile', name: 'user.profile', component: UserProfile, props: true },
{ path: 'posts', name: 'user.posts', component: UserPosts, props: true }
]
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
return { top: 0 }
}
})
export default routerStep B — Users list (src/views/Users.vue)
<template>
<div>
<h1>Users</h1>
<ul>
<li v-for="user in users" :key="user.id">
<RouterLink :to="{ name: 'user', params: { id: user.id } }">{{ user.name }}</RouterLink>
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue'
const users = ref([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
{ id: '3', name: 'Charlie' }
])
</script>Step C — User layout (src/views/UserLayout.vue)
<template>
<div>
<nav>
<RouterLink :to="{ name: 'user.profile', params: { id } }">Profile</RouterLink>
<RouterLink :to="{ name: 'user.posts', params: { id } }">Posts</RouterLink>
</nav>
<h2>User ID: {{ id }}</h2>
<!-- child views render here -->
<router-view />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const id = computed(() => route.params.id)
</script><template>
<div>
<h3>Profile for user {{ id }}</h3>
<div v-if="loading">Loading…</div>
<div v-else>Name: {{ data.name }} <br> Email: {{ data.email }}</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const id = () => route.params.id
const loading = ref(true)
const data = ref({})
async function load() {
loading.value = true
// simulate API
data.value = await fakeFetchUser(id())
loading.value = false
}
onMounted(load)
watch(() => route.params.id, () => {
load()
})
</script>Step E — Lazy loaded posts (src/views/UserPosts.vue)
<template>
<div>
<h3>Posts for {{ id }}</h3>
<ul>
<li v-for="p in posts" :key="p.id">{{ p.title }}</li>
</ul>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const id = () => route.params.id
const posts = ref([])
onMounted(async () => {
posts.value = await fakeFetchPosts(id())
})
</script>If you follow these steps and navigate to /users/1, you’ll see the layout and default child (profile). Click on Posts to lazy-load that chunk (UserPosts) and render it inside the nested <router-view>.
Create a breadcrumb component that reads route.matched to build links for each matched route record (parent ? child). This is useful for nested routes and demonstrates how the router exposes matched route records.
<template>
<nav aria-label="breadcrumb">
<ol>
<li v-for="rec in $route.matched" :key="rec.path">
<RouterLink :to="{ path: rec.path }">{{ rec.name || rec.path }}</RouterLink>
</li>
</ol>
</nav>
</template>Advanced patterns & tips
Scroll behavior
Use the router's scrollBehavior option to control scroll on navigation — e.g. preserve saved position on popstate, or scroll to top on new route. We showed a sample in the router config earlier.
Transitions between routes
Use <router-view v-slot="{ Component }"> to animate route changes with transitions:
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>Active links and nested routes
<RouterLink> automatically applies active classes. When using nested routes, ancestor routes are considered active if params match, which is handy for open/active states in sidebars. :contentReference[oaicite:7]{index=7}
Store route-level data like titles or auth flags in meta. The router merges meta from parent to child on match, and you can read route.meta to implement behaviors (e.g., set document.title on navigation).
{ path: '/admin', component: Admin, meta: { requiresAuth: true, title: 'Admin' } }Testing routes
For unit tests, avoid coupling components to the global router. Either mount components with a mocked router (Vue Test Utils supports creating a router instance) or pass route-like props directly (favored when using props mode).
Performance & accessibility
Lazy-load route components, keep route code small, and consider prefetching important chunks. For accessibility, ensure your in-page navigation and focus management behave after route changes (e.g., move focus to main heading after navigation).
Route troubleshooting checklist (common gotchas)
| Symptom | Cause | Fix |
|---|---|---|
| Child route not rendering | No <router-view> in parent component | Add <router-view> inside the parent |
| Param changes don't trigger reload | Component instance reused | Watch route.params or use beforeRouteUpdate |
| Named route push fails | Missing params for path | Provide required params as object: router.push({ name:'user', params:{ id } }) |
| Route not found after addRoute | Route name conflict or add timing issue | Ensure names are unique; add routes before initial navigation if needed; use router.hasRoute() |
Bonus: SEO & deep links
For SPAs, deep linking works when you use the proper history mode and configure your server to fallback to index.html. For server-side rendering (SSR) you'd use a universal approach; Vue Router's isReady() is helpful during hydration to wait for async hooks to resolve before rendering the initial markup. :contentReference[oaicite:8]{index=8}
Best practices & real-world recommendations
- Prefer named routes for programmatic navigation. Names decouple your code from path strings.
- Pass params as props to components. Keep components router-agnostic and easier to test.
- Lazy-load large route components. Improves first paint and perceived performance.
- Use per-route meta for policies (auth, title). Centralizes route-specific behavior.
- Watch route.params for data refreshes. Component reuse helps performance but requires explicit reactivity.
- Keep route configuration modular. Split routes into files (adminRoutes, userRoutes) and import them to keep router config readable.
Full minimal example: router/index.js + main.js
Here is a compact, copy-pasteable router setup you can use as a base:
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const Home = () => import('../views/Home.vue')
const Users = () => import('../views/Users.vue')
const UserLayout = () => import('../views/UserLayout.vue')
const UserProfile = () => import('../views/UserProfile.vue')
const UserPosts = () => import('../views/UserPosts.vue')
const routes = [
{ path: '/', name: 'home', component: Home },
{ path: '/users', name: 'users', component: Users },
{
path: '/users/:id',
name: 'user',
component: UserLayout,
props: true,
children: [
{ path: '', name: 'user.home', component: UserProfile, props: true },
{ path: 'profile', name: 'user.profile', component: UserProfile, props: true },
{ path: 'posts', name: 'user.posts', component: UserPosts, props: true }
]
},
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('../views/NotFound.vue') }
]
export const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior() { return { top: 0 } }
})
// sample dynamic route add (run at runtime)
export function addBetaRoute() {
router.addRoute({ path: '/beta', name: 'beta', component: () => import('../views/Beta.vue') })
}
export default router
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
router.isReady().then(() => app.mount('#app'))Common mistakes & how I avoid them (real-world tips)
Personal note: early in my Vue journey I frequently hard-coded path strings all over components. One refactor later (changing a path), I had to hunt through code. That’s when I switched to named routes and props, and I never looked back. Here are practical habits I recommend:
- Always use named routes for programmatic navigation — easier refactors.
- Centralize route names as constants in large apps to avoid typos.
- Use
propsfor components, so components don't read$routedirectly. - Test route guards by mocking authentication state — don't rely on manual QA only.
When you want to deep-dive, the official Vue Router docs are the authoritative source for syntax and edge cases. In this guide I leaned on the official docs for dynamic matching, nested routes, programmatic navigation, lazy loading, and dynamic routing APIs. For details and examples, check the Vue Router docs referenced below. :contentReference[oaicite:9]{index=9}
Final checklist before you ship routing features
- All important routes covered by tests or manual QA.
- Proper redirects and 404 page configured.
- Auth-protected routes guarded with meta + guard.
- Lazy-load heavy pages; monitor chunk sizes.
- Server routing fallback configured for history mode.
- Accessibility checks (focus management after navigation).
Closing thoughts: Routing might feel complex at first, but once you internalize route records, params, children, and the router composables (useRoute, useRouter), it becomes one of the most empowering tools in a Vue dev’s toolbox. Build small examples, play with nested routes and lazy-loading, and you’ll quickly see how predictable and elegant your app’s navigation can become.






