Lazy loading and 404
Assignment
The volunteer has found two problems, and Martina Hostettler turns them into an assignment.
As a member, I want a hint and a way back when I open a wrong address, so that I'm not left with an empty page.
The acceptance criteria are:
- An address without a page shows "Page not found" with a link to the catalog.
- The code for the team area only loads when someone opens the area.
- After every change of page, the title of the tab is right, and the focus is on the heading of the new page.
Learning goals
After this chapter you can
- define a route for all unknown addresses,
- restrict a param with a regular expression,
- load routes on demand and spot the result in the build,
- control the scroll position with
scrollBehavior, - set the title and the focus after a navigation.
A page for unknown addresses
Section titled “A page for unknown addresses”If no route matches an address, RouterView shows nothing, and Vue Router reports No match found for location with path in the console. The documentation catches such addresses with a route whose param has a regular expression of its own.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
const route = useRoute()</script>
<template> <nav> <RouterLink to="/">Catalog</RouterLink> <RouterLink to="/association">Association</RouterLink> <RouterLink to="/club-night/2026">An old link</RouterLink> </nav> <p>Address: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>AssociationView.vue
<template> <h1>Association</h1> <p>Lorraine Commons lends things that people rarely need.</p></template>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')NotFoundView.vue
<script setup>import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()</script>
<template> <h1>Page not found</h1> <p>There is no page at this address.</p> <p>pathMatch: {{ JSON.stringify(route.params.pathMatch) }}</p> <RouterLink to="/">Back to the catalog</RouterLink></template>router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import AssociationView from './AssociationView.vue'import CatalogView from './CatalogView.vue'import NotFoundView from './NotFoundView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView }, { path: '/association', component: AssociationView }, // (.*)* matches every path that no other route matches, also over several segments { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView }, ],})(.*) matches any text, even across several segments of the address. The asterisk after it makes the param optional and repeatable, and route.params.pathMatch contains the segments as an array, which for /club-night/2026 is ["club-night", "2026"]. The route can go anywhere in the list, because according to the documentation the order of the routes doesn't matter.
The same mechanism restricts a param. /items/:id(\\d+) only matches digits, and /items/abc ends up on the page for unknown addresses. The double backslashes are needed because the path sits in a JavaScript string. Only the page itself can detect a number without a thing, because the router doesn't know the list of things.
Loading routes on demand
Section titled “Loading routes on demand”An import at the top of a file loads a component when the app starts. If a route uses a function with import() instead, the router only loads the component on the first visit and keeps it after that. The documentation recommends this for all routes.
{ path: '/team', // The code of the team area loads only when someone opens it component: () => import('@/views/TeamView.vue'),},During the build, Vite puts every component loaded this way into a file of its own. In the exercise project, npm run build then shows an extra line for the team area, shortened roughly like this.
dist/index.html 0.40 kBdist/assets/index-tJDboEPA.css 1.02 kBdist/assets/TeamView-toIM6wF9.js 0.27 kBdist/assets/index-Di6fJri9.js 95.09 kBThe characters after the hyphen and the sizes will look different for you. In the Network tab of the developer tools, the file only shows up when you open the area. The playground loads all files of an example at once, so the difference doesn't show there. The running case app loads the team area on demand. The other pages are small, and nearly all of them belong to the first visit anyway.
The scroll position
Section titled “The scroll position”When the page changes, the browser leaves the scroll position where it was, because it doesn't load a new page. The scrollBehavior option of the router decides where to go after a navigation.
App.vue
<script setup>import { RouterView } from 'vue-router'</script>
<template> <RouterView /></template>CatalogView.vue
<script setup>import { RouterLink } from 'vue-router'
import { things } from './things.js'</script>
<template> <h1>Catalog</h1> <ul> <li v-for="thing in things" :key="thing.id"> <RouterLink :to="{ name: 'item', params: { id: thing.id } }">{{ thing.name }}</RouterLink> </li> </ul></template>ItemView.vue
<script setup>import { computed } from 'vue'import { RouterLink, useRoute } from 'vue-router'
import { things } from './things.js'
const route = useRoute()
const thing = computed(() => things.find((entry) => entry.id === Number(route.params.id)))const others = computed(() => things.filter((entry) => entry !== thing.value))</script>
<template> <h1>{{ thing?.name }}</h1> <RouterLink :to="{ name: 'catalog' }">Back to the list</RouterLink> <h2>More things</h2> <ul> <li v-for="entry in others" :key="entry.id"> <RouterLink :to="{ name: 'item', params: { id: entry.id } }">{{ entry.name }}</RouterLink> </li> </ul></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 ItemView from './ItemView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', name: 'catalog', component: CatalogView }, { path: '/items/:id', name: 'item', component: ItemView }, ], // Back and forward return to the old position, every other page starts at the top scrollBehavior(to, from, savedPosition) { return savedPosition ?? { top: 0 } },})things.js
// Every thing of the catalog, long enough to scrollexport const things = [ { id: 1, name: 'Drill' }, { id: 2, name: 'Tent for four' }, { id: 3, name: 'Raclette oven' }, { id: 4, name: 'Beer bench set' }, { id: 5, name: 'Jigsaw' }, { id: 6, name: 'Projector' }, { id: 7, name: 'Ladder' }, { id: 8, name: 'Sleeping bag' }, { id: 9, name: 'Bike trailer' }, { id: 10, name: 'Lawn mower' }, { id: 11, name: 'Cordless screwdriver' }, { id: 12, name: 'Sander' }, { id: 13, name: 'Spirit level' }, { id: 14, name: 'Toolbox' }, { id: 15, name: 'Circular saw' }, { id: 16, name: 'Tile cutter' }, { id: 17, name: 'Staple gun' }, { id: 18, name: 'Hedge trimmer' }, { id: 19, name: 'Leaf blower' }, { id: 20, name: 'Lawn scarifier' }, { id: 21, name: 'Wheelbarrow' }, { id: 22, name: 'Lopper' }, { id: 23, name: 'Pressure washer' }, { id: 24, name: 'Garden shredder' }, { id: 25, name: 'Spade' }, { id: 26, name: 'Compost sieve' }, { id: 27, name: 'Fondue set' }, { id: 28, name: 'Preserving pot' }, { id: 29, name: 'Food dehydrator' }, { id: 30, name: 'Pasta maker' }, { id: 31, name: 'Waffle iron' }, { id: 32, name: 'Stand mixer' }, { id: 33, name: 'Ice cream maker' }, { id: 34, name: 'Juicer' }, { id: 35, name: 'Stockpot for 50 people' }, { id: 36, name: 'Tent for two' }, { id: 37, name: 'Camping stove' }, { id: 38, name: 'Sleeping mat' }, { id: 39, name: 'Backpack 65 liters' }, { id: 40, name: 'Cool box' }, { id: 41, name: 'Camping table' }, { id: 42, name: 'Headlamp' }, { id: 43, name: 'Trekking poles' }, { id: 44, name: 'Stroller' }, { id: 45, name: 'Travel cot' }, { id: 46, name: 'Child bike seat' }, { id: 47, name: 'Balance bike' }, { id: 48, name: 'Baby carrier' }, { id: 49, name: 'High chair' }, { id: 50, name: 'Sled' }, { id: 51, name: 'Ride-on car' }, { id: 52, name: 'Board game box' }, { id: 53, name: 'Projection screen' }, { id: 54, name: 'Speaker' }, { id: 55, name: 'Party tent' }, { id: 56, name: 'String lights' }, { id: 57, name: 'Drinks dispenser' }, { id: 58, name: 'Popcorn machine' }, { id: 59, name: 'Microphone' }, { id: 60, name: 'Coffee urn for 40 cups' },]In the example, the page of a thing starts at the top, even if you clicked far down in the list. According to the documentation, savedPosition only exists for back and forward in the history of the browser. Then a page returns to its old spot, and every other page starts at the top. The memory history of the playground keeps no positions, so you try the back button with the saved position in the running case app.
Title and focus after navigation
Section titled “Title and focus after navigation”router.afterEach runs after every navigation and can no longer stop it. The app uses it to set the title of the tab from a title field in meta. A third argument reports a navigation that was cancelled or led to the current page, and then the app changes nothing.
App.vue
<script setup>import { RouterLink, RouterView } from 'vue-router'</script>
<template> <nav> <RouterLink to="/">Catalog</RouterLink> <RouterLink to="/association">Association</RouterLink> </nav> <main> <RouterView /> </main></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>AssociationView.vue
<template> <h1 tabindex="-1">Association</h1> <p>Lorraine Commons lends things that people rarely need.</p></template>CatalogView.vue
<template> <!-- tabindex="-1" lets the heading take the focus without a stop for the Tab key --> <h1 tabindex="-1">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 { nextTick } from 'vue'import { createMemoryHistory, createRouter, START_LOCATION } from 'vue-router'
import AssociationView from './AssociationView.vue'import CatalogView from './CatalogView.vue'
const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView, meta: { title: 'Catalog' } }, { path: '/association', component: AssociationView, meta: { title: 'Association' } }, ],})
// Runs after every navigation, it can no longer stop itrouter.afterEach(async (to, from) => { document.title = `${to.meta.title} · Lorraine Commons` console.log('Title', document.title) // On the first page the browser starts at the top anyway if (from === START_LOCATION) return // The new page is only in the DOM after Vue has rendered it await nextTick() document.querySelector('main h1')?.focus()})
export default routerIn an SPA, the browser doesn't load a new page, and a screen reader doesn't notice the change by itself. In a user study by Gatsby with people with disabilities, the focus on the heading of the new page did best. For this, the heading gets tabindex="-1", so that it can take the focus without showing up in the order of the Tab key. The focus is set after await nextTick(), because afterEach runs before Vue renders the new page. On the first load, from is START_LOCATION, and the focus stays where the browser has it.
The running case app first checks whether the new page has already put the focus into main itself. The catalog does that with its search field from chapter 5.3 and keeps it. The page of a thing sets its own title, because only that page knows the name of the thing. Chapter 8.5 comes back to further techniques, such as a live region that announces the title of the new page.
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 first example, remove the route for unknown addresses and click "An old link". What does the console report, and what does the page show?
- In the same example, change the link to
/club-night. What doespathMatchcontain now? - In the example with the long list, scroll down and open a thing. Then remove
scrollBehaviorand do it again. Where does the page of the thing start? - In the example on title and focus, remove the line with
await nextTick(). Where is the focus after a change of page?
Exercises
Section titled “Exercises”The exercises build the points from the assignment. You'll find the project in the course repo in the folder exercises/06-routing/lazy-404/en. Copy the folder, install the packages and start the tests.
npm installnpm testExercise Getting started
Add a route for all unknown addresses with the name not-found. It shows NotFoundView and has the title "Page not found" in its meta field. After that, /items/abc ends up there as well.
Solution
// Every path that no other route matches, also over several segments { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { title: 'Page not found' } },The route for a thing already has (\\d+) and only matches digits. For /items/abc, only the route for unknown addresses is left.
Exercise Going further
Load TeamView only when someone opens the area. Then check with npm run build whether a file of its own appears for the area.
Solution
{ path: '/team', name: 'team', // The code of the team area loads only when someone opens it component: () => import('@/views/TeamView.vue'), meta: { title: 'Team at the counter' }, },The import of TeamView at the top of the file goes away, otherwise the component would land in the first bundle despite the function. After the first visit, the router replaces the function with the loaded component.
Exercise Challenge
After every navigation, set the title of the tab to the title from meta followed by " · Lorraine Commons". Also set the focus on the h1 heading of the new page, but not on the first load. For this, the headings need tabindex="-1".
Solution
// Runs after every navigation and can no longer stop itrouter.afterEach(async (to, from, failure) => { // A cancelled navigation, or one to the current page, changes nothing if (failure) return if (to.meta.title) document.title = `${to.meta.title} · Lorraine Commons` // On the first load the browser starts at the top of the page anyway if (from === START_LOCATION) return // The new page is only in the DOM after Vue has rendered it await nextTick() document.querySelector('main h1')?.focus()})START_LOCATION comes from vue-router, and nextTick comes from vue. All pages now have their heading as <h1 tabindex="-1">.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
Vue Router reports Catch all routes ("*") must now be defined using a param with a custom regexp. | path: '*' comes from Vue Router 3. The route is now /:pathMatch(.*)*. |
A route loads its component with defineAsyncComponent. | According to the documentation, a route takes a plain function with import(), not an async component. |
| After a change of page, the focus lands nowhere. | The heading needs tabindex="-1", and the focus waits for the new page with nextTick. |
| After the back button, the list is at the top instead of its old spot. | scrollBehavior returns savedPosition when there is one. |
Self-check
Section titled “Self-check”Self-check
- What does
route.params.pathMatchcontain for the address/club-night/2026? - What changes in the build when a route loads its component with
import()? - When does
scrollBehaviorget a saved position? - Why does the app set the focus after a navigation but not on the first load?
Answers
- The array
["club-night", "2026"], with one entry for each segment of the address. - Vite puts the component into a file of its own, which the browser only loads on the first visit to the route.
- Only for back and forward in the browser.
- On the first load, the browser starts at the top of the new page, as with any website. On a change inside the SPA, no page loads, and without the focus a screen reader doesn't notice the change.
Assignment
The app has pages, addresses to share and an area for the team. Its data still lives in the browser, though. Only someone working in the same browser sees a return at the counter, and after a reload it's gone. Part 7 fetches the data from the server of the association and keeps shared state with Pinia. Before that, check with the competence grid whether you're ready for part 7.
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.