• Fri, Mar 2026

Dynamic & Nested Routing in Vue Router

Dynamic & Nested Routing in Vue Router

This in-depth tutorial walks you through dynamic and nested routes with Vue Router (Vue 3 + Vue Router 4).You'll learn route params, nested usage,programmatic navigation,lazy-loading route components,route guards,adding/removing routes at runtime,route meta,repeated params, breadcrumbs,transitions — all with practical code examples and step-by-step instructions.

Table of contents [Show]

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@4

Step 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 router

Step 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 dev

Dynamic 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.params as 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>

Programmatic navigation (router.push / replace / go)

Why programmatic navigation?

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>.

Waiting for navigation to finish

Because navigation methods return Promises, you can await them:

await router.push('/dashboard')
// now do something once navigation finished, e.g. close mobile menu

Lazy-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'] } }).

Navigation guards and authentication flows

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 router

Step 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>.

Breadcrumbs using route.matched

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}

Route meta fields

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)

SymptomCauseFix
Child route not renderingNo <router-view> in parent componentAdd <router-view> inside the parent
Param changes don't trigger reloadComponent instance reusedWatch route.params or use beforeRouteUpdate
Named route push failsMissing params for pathProvide required params as object: router.push({ name:'user', params:{ id } })
Route not found after addRouteRoute name conflict or add timing issueEnsure 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

  1. Prefer named routes for programmatic navigation. Names decouple your code from path strings.
  2. Pass params as props to components. Keep components router-agnostic and easier to test.
  3. Lazy-load large route components. Improves first paint and perceived performance.
  4. Use per-route meta for policies (auth, title). Centralizes route-specific behavior.
  5. Watch route.params for data refreshes. Component reuse helps performance but requires explicit reactivity.
  6. 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 props for components, so components don't read $route directly.
  • Test route guards by mocking authentication state — don't rely on manual QA only.

Where to read next (official docs & resources)

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.

This website uses cookies to enhance your browsing experience. By continuing to use this site, you consent to the use of cookies. Please review our Privacy Policy for more information on how we handle your data. Cookie Policy