Nested Routes in Vue Router 4

After reading this you will be able to define a parent-and-child route hierarchy in Vue Router 4, mount each level into the correct <router-view> outlet, and lazy-load every nested view so the parent layout ships without its children’s code.

← Back to Vue Router Configuration

Prerequisites

Core Concept

A nested route is a children array attached to a parent route record. The parent path becomes a prefix for every child, so a child with path: 'settings' under a parent at /dashboard resolves at /dashboard/settings. Crucially, nesting is not just a URL convention — it is a rendering instruction. Each matched route record is paired with exactly one <router-view> outlet in its parent’s component template. The top-level outlet (in App.vue) renders the parent layout; the parent layout must itself contain a second <router-view> to render the child. Miss that second outlet and the child component is resolved, matched, and then silently discarded with nothing painted to the screen.

Because each child is its own route record, it can carry its own meta, its own guards, and — most usefully — its own lazily-imported component. Pointing component at a () => import(...) arrow turns each nested view into a separate bundle chunk that loads only when the user navigates to it, which is the cleanest place to apply dynamic route segments and per-section code splitting without inflating the parent layout’s initial payload.

It helps to picture the matched route as a stack rather than a single record. When the user lands on /dashboard/settings, Vue Router 4 produces a matched array containing both the parent record and the child record, ordered from outermost to innermost. The router walks that array and pairs each entry with the next available <router-view> it finds while descending the component tree. The first outlet (in App.vue) receives the parent layout; the outlet declared inside that layout receives the child. This stack model is why depth is effectively unlimited — a grandchild simply adds a third entry to matched and expects a third outlet nested one level deeper. It is also why the same component instance is reused across sibling navigations: the parent record does not change between /dashboard/settings and /dashboard/profile, so only the innermost outlet swaps its component while the layout stays mounted.

Implementation

The route table declares the hierarchy; the parent template provides the outlet. Both pieces are required.

// vue-router v4.3, Vue 3.4, Vite 5.x
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import DashboardLayout from '@/layouts/DashboardLayout.vue'

const routes: RouteRecordRaw[] = [
  {
    path: '/dashboard',
    component: DashboardLayout, // parent layout — eagerly loaded, holds the child outlet
    children: [
      {
        // no leading slash: resolves at /dashboard/settings, not /settings
        path: 'settings',
        name: 'dashboard-settings',
        component: () => import('@/views/DashboardSettings.vue'), // own chunk
        meta: { title: 'Settings', requiresAuth: true }
      },
      {
        path: 'profile/:userId', // dynamic segment scoped under the parent
        name: 'dashboard-profile',
        component: () => import('@/views/DashboardProfile.vue'),
        meta: { title: 'Profile', requiresAuth: true }
      },
      {
        // empty path = the default child rendered at the bare /dashboard URL
        path: '',
        name: 'dashboard-home',
        component: () => import('@/views/DashboardHome.vue')
      }
    ]
  }
]

export const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

The parent layout supplies the outlet that the matched child renders into. The v-slot form gives you the resolved component so you can wrap it in a transition without losing the child:

<!-- DashboardLayout.vue — vue-router v4.3, Vue 3.4 -->
<template>
  <div class="dashboard-layout">
    <nav role="navigation">
      <!-- aria-current is set automatically on the matched link -->
      <router-link :to="{ name: 'dashboard-settings' }">Settings</router-link>
      <router-link :to="{ name: 'dashboard-profile', params: { userId: 'me' } }">Profile</router-link>
    </nav>
    <main>
      <!-- the SECOND outlet: this is where children mount; omit it and they vanish -->
      <router-view v-slot="{ Component }">
        <transition name="fade" mode="out-in">
          <!-- key by fullPath so navigating between sibling children re-triggers the transition -->
          <component :is="Component" :key="$route.fullPath" />
        </transition>
      </router-view>
    </main>
  </div>
</template>

Verification

Confirm both the URL prefixing and the chunk splitting actually happened. Build first, then inspect:

// TypeScript 5.x — framework-agnostic; run with @playwright/test v1.4x
import { test, expect } from '@playwright/test'

test('nested child mounts and loads its own chunk', async ({ page }) => {
  const chunkRequests: string[] = []
  page.on('request', (r) => {
    if (r.url().includes('DashboardSettings')) chunkRequests.push(r.url())
  })

  await page.goto('/dashboard') // default child renders, settings chunk not yet fetched
  expect(chunkRequests).toHaveLength(0)

  await page.getByRole('link', { name: 'Settings' }).click()
  await expect(page).toHaveURL(/\/dashboard\/settings$/)        // parent prefix applied
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible() // child painted
  expect(chunkRequests.length).toBeGreaterThan(0)              // chunk fetched on demand
})

For a quick manual check, open DevTools → Network, filter on JS, and navigate into a child: a new chunk request appearing only on navigation proves the lazy import is working rather than being bundled into the parent. A second useful probe is to log router.currentRoute.value.matched from the console after navigating — you should see two entries for a one-level-deep child, the parent first and the child second. If matched contains the child but the screen is blank, the route table is correct and the fault is a missing outlet rather than a matching problem; if matched is missing the child entirely, the path or children nesting is wrong and the parent template is not the issue.

Gotchas

  • Leading slash on a child path resets the prefix. Writing path: '/settings' inside children makes the route resolve at /settings, not /dashboard/settings. Only the parent path carries a leading slash.
  • A missing child <router-view> fails silently. Vue Router 4 requires an explicit outlet for every matched level. There is no warning when a parent layout omits its outlet — the child simply never appears, unlike Vue 2’s more forgiving behaviour.
  • The empty-path child is not optional fallback. path: '' renders the default child at the parent URL, but it is matched, not a catch-all. For unmatched nested URLs you still need fallback routing strategies such as a :pathMatch(.*)* child.
  • Transitions need a key. Without :key="$route.fullPath", navigating between two sibling children that share the same component reuses the instance and skips the transition entirely. The same reuse means lifecycle hooks like onMounted will not fire on the second navigation, so any per-child setup belongs in a watch on the route params instead.
  • Per-child guards run on every entry, layout guards do not. A beforeRouteUpdate inside a child fires when navigating between params of that same child, but beforeRouteEnter does not re-run because the component is reused. If your nested layout needs to react to deep navigation, drive it from a router.beforeEach global guard or a watch, not from the child’s enter hook.
  • Relative <router-link> targets resolve against the matched parent, not the URL bar. Inside a child rendered at /dashboard/settings, a link with to="profile" resolves relative to the active record and can land somewhere unexpected. Prefer named routes with explicit params for nested links so refactoring a parent path does not silently break every child link.

FAQ

Why do my Vue Router 4 child routes render blank despite a correct URL? The parent component’s template is missing a <router-view> outlet, so the matched child component is resolved but has nowhere to mount. Every layout that renders children needs its own outlet in addition to the top-level one in App.vue.

Do nested child paths need a leading slash? No — and adding one breaks the nesting. A child path like settings is appended to the parent’s path to form /dashboard/settings, whereas /settings is treated as an absolute path that ignores the parent prefix entirely.

How does lazy loading interact with nested routes? Each child’s component can be a () => import(...) arrow, producing a separate bundle chunk fetched only when the user navigates to that child. The URL updates synchronously via pushState, but the component resolves once the import settles, so a transition or loading state prevents a blank flash.

Can I render multiple sibling views at one nesting level? Yes, using named views: give the parent template several <router-view name="..."> outlets and supply a components map (plural) on the route record instead of a single component. Each named outlet renders the entry matching its name.

Does the empty-path child act as a not-found fallback? No. path: '' renders a default child only at the exact parent URL. To catch unmatched nested URLs you add a separate child with a :pathMatch(.*)* pattern that maps to a not-found view.