Nested routes and layouts
Assignment
The team at the counter needs an area of its own in the app.
As a volunteer at the counter, I want to find the loans and the returns under a shared address, so that I can switch between the two quickly.
The acceptance criteria are:
/teamlists the things that are on loan, with the next return first.- At
/team/returns, the team records a return, and the catalog then shows the thing as available. - Both pages share a heading and a navigation that shows where you are.
Learning goals
After this chapter you can
- define child routes with
children, - build a layout with a
RouterViewof its own, - use a child route with an empty path as the start page of an area,
- tell active links from exactly active links,
- spread several components over one route with named views.
Child routes
Section titled “Child routes”A route can have child routes. They go into children, and their paths are appended to the path of the parent route, so returns under /team becomes /team/returns. The component of the parent route appears in the RouterView of App. The component of the child route appears in a second RouterView inside the parent component.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
const route = useRoute()</script>
<template> <nav> <RouterLink to="/">Catalog</RouterLink> <RouterLink to="/team">Team</RouterLink> </nav> <p>Address: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}/* Also active on every page below the link, here on the pages of the team */.router-link-active { font-weight: 700;}</style>CatalogView.vue
<template> <h1>Catalog</h1> <p>60 things to borrow.</p></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import CatalogView from './CatalogView.vue'import TeamLayout from './TeamLayout.vue'import TeamLoansView from './TeamLoansView.vue'import TeamReturnsView from './TeamReturnsView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView }, { path: '/team', component: TeamLayout, // The children appear in the RouterView of TeamLayout children: [ // The empty path shows the loans at /team itself { path: '', component: TeamLoansView }, { path: 'returns', component: TeamReturnsView }, ], }, ],})TeamLayout.vue
<script setup>import { RouterLink, RouterView } from 'vue-router'</script>
<template> <h1>Team at the counter</h1> <nav> <RouterLink to="/team">Loans</RouterLink> <RouterLink to="/team/returns">Returns</RouterLink> </nav> <!-- The page of the child route appears here, inside the layout --> <RouterView /></template>
<style scoped>nav { display: flex; gap: 1rem;}/* Only the link to exactly this page, the loans link is active on every child */.router-link-exact-active { text-decoration: underline 3px;}</style>TeamLoansView.vue
<template> <h2>Loans</h2> <ul> <li>Tent for four, back from 11/12/2026</li> <li>Beer bench set, back from 11/3/2026</li> </ul></template>TeamReturnsView.vue
<template> <h2>Returns</h2> <p>This is where the team records what comes back.</p></template>The child route with the empty path '' belongs to the address /team itself and is the start page of the area. Without it, /team would only show the heading and an empty space. According to the documentation, a child path that starts with / would count from the root, which is why child paths have no leading slash.
Layouts
Section titled “Layouts”The parent component of an area is its layout. TeamLayout has the heading and the navigation that all pages of the team share, plus a RouterView for the page that's currently showing. When someone switches from the loans to the returns, the layout stays in place, and only the content inside it changes.
The figure shows all routes of the running case app after this part. The dashed boxes only load on demand, and the team area needs a login. Both come in the next two chapters.
Active and exactly active links
Section titled “Active and exactly active links”In the example, the "Team" link in the header is bold on both pages of the area. It's active because the current route lies below its route. The "Loans" link points to the empty child route and therefore counts as active on all pages of the area. Only the link to the page that's currently open is exactly active, and only that link gets router-link-exact-active and aria-current="page". The navigation in the header therefore styles router-link-active, and the navigation of the area styles router-link-exact-active.
Named views
Section titled “Named views”A page can have more than one place for components, such as a column next to the content. A RouterView with a name is such a second place. A route fills it with components instead of component, an object with a component for each name and default for the RouterView without a name.
App.vue
<script setup>import { RouterLink, RouterView } from 'vue-router'</script>
<template> <nav> <RouterLink to="/">Catalog</RouterLink> <RouterLink to="/team">Team</RouterLink> </nav> <div class="columns"> <RouterView /> <!-- A second, named place, it stays empty when the route has nothing for it --> <RouterView name="aside" /> </div></template>
<style scoped>nav,.columns { display: flex; flex-wrap: wrap; gap: 1rem;}</style>CatalogView.vue
<template> <section> <h1>Catalog</h1> <p>60 things to borrow.</p> </section></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import CatalogView from './CatalogView.vue'import TeamHints from './TeamHints.vue'import TeamLoansView from './TeamLoansView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView }, // components instead of component, one component for every named view { path: '/team', components: { default: TeamLoansView, aside: TeamHints } }, ],})TeamHints.vue
<template> <aside class="hints"> <h2>Hints</h2> <p>The deposit is paid back on return.</p> </aside></template>
<style scoped>.hints { padding: 0 1rem; border-left: 3px solid #1f6b4f;}</style>TeamLoansView.vue
<template> <section> <h1>Loans</h1> <p>Tent for four, back from 11/12/2026</p> </section></template>In the catalog, the column stays empty, because its route has nothing for it. Named views pay off when several routes fill the same page with different parts. The running case app doesn't need any, but the challenge shows them in use once.
Data for the pages of the router
Section titled “Data for the pages of the router”Up to part 5, App passed the pages their data as props. Now the router shows the pages, and App no longer has them in its template. In the running case app, App therefore provides the catalog, the favorites and the signed-in member with provide, like the member in chapter 4.5, and every page takes what it needs with inject. The catalog comes with the function markReturned, with which the page for returns marks a thing as available. Until part 7 this only holds in your own browser. Then every return goes to the server of the association, and Pinia holds the shared state.
In code
Section titled “In code”Work with the examples of this chapter. Read the code and write down what you expect before you change anything.
- In the example with the child routes, open the returns. Which links are bold, which are underlined, and why?
- Remove
RouterViewfromTeamLayout. What does/teamshow now? - Write the path of the returns as
/returnsand adjust the link in the layout. At which address do the returns appear, and does the layout still appear? - In the example with the named views, open the catalog and the team in turn. What happens to the column?
Exercises
Section titled “Exercises”The exercises build the team area from the assignment. You'll find the project in the course repo in the folder exercises/06-routing/nested-routes/en. Copy the folder, install the packages and start the tests.
npm installnpm testExercise Getting started
Turn the flat page TeamView into the layout TeamLayout with the heading "Team at the counter" and a RouterView. Move the list of loans and its heading into TeamLoansView. In src/router/index.js, /team gets the layout as its component and a child route with an empty path and the name team-loans.
Solution
{ path: '/team', // The layout fills the RouterView of App, the hints the RouterView named aside components: { default: TeamLayout, aside: TeamHints }, // The children appear in the RouterView of TeamLayout children: [ { path: '', name: 'team-loans', component: TeamLoansView }, { path: 'returns', name: 'team-returns', component: TeamReturnsView }, ], },The solution already contains the second child route and the column from the challenge. For getting started, the route has component: TeamLayout and only the child route with the empty path. TeamLayout holds the heading and a RouterView, and TeamLoansView holds the list.
Exercise Going further
Add the child route returns with the name team-returns for TeamReturnsView, and a navigation <nav class="team-nav"> in the layout with links to "Loans" and "Returns". The link to the current page of the area is bold.
Solution
<script setup>import { RouterLink, RouterView } from 'vue-router'</script>
<template> <h1>Team at the counter</h1> <nav class="team-nav" aria-label="Team area"> <RouterLink :to="{ name: 'team-loans' }">Loans</RouterLink> <RouterLink :to="{ name: 'team-returns' }">Returns</RouterLink> </nav> <!-- The page of the child route appears here, inside the layout --> <RouterView /></template>
<style scoped>.team-nav { display: flex; gap: 1rem;}/* Only the link to exactly this page, the link to the loans is active on every child */.team-nav a[aria-current='page'] { font-weight: 700;}</style>The selector checks aria-current, which only the exactly active link has. With router-link-active, the link to the loans would be bold on the returns as well, because it points to the empty child route.
Exercise Challenge
TeamHints should sit in a column of its own next to all pages of the team, but not next to the catalog. Add a RouterView named aside to App.vue, and give the route /team the hints as a second component.
Solution
<div class="columns"> <main> <RouterView /> </main> <!-- A second, named place, empty when the route has nothing for it --> <RouterView name="aside" /> </div>In the route, components: { default: TeamLayout, aside: TeamHints } replaces the single component. The child routes inherit the column, because it belongs to the parent route. The route of the catalog has no component for aside, and the named RouterView stays empty there.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
| A child route only shows the layout. | The layout is missing <RouterView />. |
At /team, the place for the page stays empty. | A child route with the empty path '' shows the start page of the area. |
A child page sits at /returns instead of /team/returns. | Write the child path without a leading /, otherwise it counts from the root. |
| The link to the start page of the area stays highlighted on every page. | Style router-link-exact-active or aria-current in the navigation of the area. |
An AI assistant switches the pages of the area with v-if and a variable. | With child routes, every page has an address of its own that people can share and reload. |
Self-check
Section titled “Self-check”Self-check
- Where does the component of a child route appear?
- What is a child route with an empty path for?
- How do
router-link-activeandrouter-link-exact-activediffer? - Where do the pages of the router in the running case app get their data from?
Answers
- In the
RouterViewof the parent component, which is the layout of the area. - It shows the start page of the area at the address of the parent route itself.
- A link is also active when the current route lies below its route. Only the link to the current page is exactly active, and only that link gets
aria-current="page". Appprovides the catalog, the favorites and the member withprovide, and every page takes them withinject.
Assignment
The team area is in place. Martina Hostettler still gets in touch. "Right now any member can open the area. And yesterday a member lost a half-filled form by clicking a link." Navigation guards cover both, and they come in the next chapter.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.