Your app has an account area: /account, /account/profile, /account/billing. The three pages share a heading, a tab bar and a card around the content. Copying that frame into each page works until the day you change it. Nested routes give the frame one component and let the router swap the content inside it. The frame's route can also carry what the whole section shares, such as a sign-in requirement or a breadcrumb label.
The examples use a plain Vite project with Vue 3.5 and Vue Router 4.5, with the router in src/router/index.ts. If you have not added the router to a project yet, the earlier post on adding Vue Router to a Vite app covers that setup.
Render child routes in the parent's RouterView
A route record (one object in the routes array) can carry a children array. The parent record matches the start of the URL and renders its component. Inside that component a <RouterView /> renders whichever child matched the rest.
import { createRouter, createWebHistory } from 'vue-router'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: () => import('@/views/HomeView.vue') },
{
path: '/account',
component: () => import('@/views/account/AccountLayout.vue'),
children: [
{ path: '', name: 'account', component: () => import('@/views/account/OverviewView.vue') },
{ path: 'profile', name: 'account-profile', component: () => import('@/views/account/ProfileView.vue') },
{ path: 'billing', name: 'account-billing', component: () => import('@/views/account/BillingView.vue') },
],
},
],
})
The child with path: '' is the index child. It renders when the URL is exactly /account. Leave it out and /account still shows the layout, but with an empty hole where the content should be, because no child matched.
Give the names to the children rather than the parent. Then { name: 'account' } always lands on the overview with the layout around it, and there is no second name that renders the frame on its own.
The layout is an ordinary component with a <RouterView /> where the page goes:
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<section class="account">
<h1>Your account</h1>
<nav aria-label="Account">
<RouterLink :to="{ name: 'account' }">Overview</RouterLink>
<RouterLink :to="{ name: 'account-profile' }">Profile</RouterLink>
<RouterLink :to="{ name: 'account-billing' }">Billing</RouterLink>
</nav>
<RouterView />
</section>
</template>
When the user moves from profile to billing, only the inner RouterView changes. The layout instance stays mounted, so anything it holds, an open dropdown or a search box, survives the navigation. That is the real win of nesting, beyond typing less markup.
Relative paths, and the slash that breaks them
Child paths are relative. The router joins the parent's path and the child's path with a slash, so 'billing' under /account becomes /account/billing. An easy mistake is to start the child path with a slash:
{
path: '/account',
component: AccountLayout,
children: [
{ path: '/billing', name: 'account-billing', component: BillingView },
],
}
The nested routes guide states the rule: nested paths that start with / are treated as root paths. So the billing page now lives at /billing, and /account/billing matches nothing. Open /account/billing in development and the console says so: [Vue Router warn]: No match found for location with path "/account/billing".
The page at /billing still renders inside AccountLayout, because it is still a child record. That is why the rule exists: it lets you nest components without nesting the URL. It is a feature when you choose it and a puzzle when you did not. If you wanted /account/billing, drop the slash:
{
path: '/account',
component: AccountLayout,
children: [
{ path: 'billing', name: 'account-billing', component: BillingView },
],
}
Params work the same way. A child with path: 'invoices/:id' becomes /account/invoices/:id, and route.params.id is available in the child and in the layout.
A help panel through named views
Sometimes a page needs to change more than one area of the layout. The billing page wants a help panel beside it; the profile page does not. Named views give a child route more than one component.
{
path: 'billing',
name: 'account-billing',
components: {
default: () => import('@/views/account/BillingView.vue'),
aside: () => import('@/views/account/BillingHelp.vue'),
},
}
The key is components, plural. default fills the unnamed <RouterView />; every other key fills a <RouterView> with a matching name. So in the layout's template, the single <RouterView /> becomes two, side by side:
<div class="account-grid">
<RouterView />
<RouterView name="aside" />
</div>
A child that only has component leaves the aside view empty, which is what the profile page wants. Keep named views to one or two. If a layout needs five of them, the pages differ too much to share a frame, and two layouts will be easier to read.
Give the public pages a layout route
Nothing about a record says "layout". A layout is a parent record whose component is mostly a <RouterView />. That means you can give the public pages their own frame by making / the parent:
routes: [
{
path: '/',
component: () => import('@/layouts/PublicLayout.vue'),
children: [
{ path: '', name: 'home', component: () => import('@/views/HomeView.vue') },
{ path: 'pricing', name: 'pricing', component: () => import('@/views/PricingView.vue') },
],
},
{
path: '/account',
component: () => import('@/views/account/AccountLayout.vue'),
children: [/* as above */],
},
]
PublicLayout.vue is a header, a <RouterView /> and a footer. The parent path / plus the child path '' is /; plus 'pricing' is /pricing. The user sees no trace of the layout in the address bar, and the two sections of the site have different frames without a single v-if in App.vue.
Group routes without a wrapper component
Sometimes you want the grouping without any frame: a shared path prefix or a shared guard, but no extra markup. Since Vue Router 4.1, a parent record may leave out component altogether. The router then renders the matched child in the nearest <RouterView /> above, as if the parent were not there:
{
path: '/admin',
meta: { requiresAuth: true },
children: [
{ path: '', name: 'admin', component: () => import('@/views/admin/AdminOverview.vue') },
{ path: 'users', name: 'admin-users', component: () => import('@/views/admin/AdminUsers.vue') },
],
}
Both admin pages render straight into the <RouterView /> in App.vue, and both inherit requiresAuth from the parent. The guard below reads it.
Guard a whole section with route meta
meta is a free-form object on a route record for anything the router does not understand itself: whether a page needs a login, a label for the breadcrumb, a page title. Type it once by adding your fields to Vue Router's RouteMeta interface with a declare module block (a module augmentation), so TypeScript checks every route.meta access:
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
breadcrumb?: string
}
}
router.beforeEach((to) => {
if (to.meta.requiresAuth && !isSignedIn()) {
return { name: 'login', query: { redirect: to.fullPath } }
}
})
isSignedIn() stands for your own sign-in check, and login is a route you add yourself. Returning a location from beforeEach redirects there, and the redirect query keeps the page the user asked for so the sign-in page can send them back.
Put meta: { requiresAuth: true } on the /account parent and every child is covered. In Vue Router 4, to.meta is a merge of the meta of every matched record, parent first and child last, so a child can override a value from its parent and you do not need to loop over the records yourself.
The records behind that merge are in to.matched: an array from the outermost record to the innermost, each with its own path, name, meta and components. Everything that needs to know "which frame am I in" reads that array.
Build breadcrumbs from route.matched
Breadcrumbs are the clearest use of route.matched. Give the layout record meta: { breadcrumb: 'Account' } and the non-index children meta: { breadcrumb: 'Profile' } and meta: { breadcrumb: 'Billing' }. Skip the index child, because its path resolves to the same /account as its parent and you would get the label twice. Then one component turns the array into links:
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
const crumbs = computed(() =>
route.matched.flatMap(record =>
record.meta.breadcrumb ? [{ label: record.meta.breadcrumb, path: record.path }] : [],
),
)
</script>
<template>
<nav aria-label="Breadcrumb">
<ol>
<li v-for="(crumb, i) in crumbs" :key="crumb.path">
<RouterLink v-if="i < crumbs.length - 1" :to="crumb.path">{{ crumb.label }}</RouterLink>
<span v-else aria-current="page">{{ crumb.label }}</span>
</li>
</ol>
</nav>
</template>
flatMap does the filtering and the mapping in one pass and keeps the type narrow: with the RouteMeta declaration from earlier, TypeScript knows label is a string because the branch that returns it has already checked it. The last crumb is the current page, so it is text rather than a link, with aria-current="page" for screen readers. Render <AppBreadcrumbs /> in the layouts, above the <RouterView />, and it updates on every navigation because useRoute() is reactive.
One limit to know: record.path for a record with params still contains :id. For a breadcrumb that must link to /account/invoices/42, give that record a name and build the link with { name: record.name, params: route.params } instead of the raw path.