Skip to content

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:

  1. An address without a page shows "Page not found" with a link to the catalog.
  2. The code for the team area only loads when someone opens the area.
  3. 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.

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

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

AssociationView.vue
<template>
<h1>Association</h1>
<p>Lorraine Commons lends things that people rarely need.</p>
</template>

CatalogView.vue

CatalogView.vue
<template>
<h1>Catalog</h1>
<p>60 things to borrow.</p>
</template>

main.js

main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
createApp(App).use(router).mount('#app')

NotFoundView.vue

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

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.

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 kB
dist/assets/index-tJDboEPA.css 1.02 kB
dist/assets/TeamView-toIM6wF9.js 0.27 kB
dist/assets/index-Di6fJri9.js 95.09 kB

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

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

App.vue
<script setup>
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>

CatalogView.vue

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

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

main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
createApp(App).use(router).mount('#app')

router.js

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

things.js
// Every thing of the catalog, long enough to scroll
export 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.

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

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

AssociationView.vue
<template>
<h1 tabindex="-1">Association</h1>
<p>Lorraine Commons lends things that people rarely need.</p>
</template>

CatalogView.vue

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

main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
createApp(App).use(router).mount('#app')

router.js

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

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

Work with the examples of this chapter. Read the code and write down what you expect before you change anything.

  1. 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?
  2. In the same example, change the link to /club-night. What does pathMatch contain now?
  3. In the example with the long list, scroll down and open a thing. Then remove scrollBehavior and do it again. Where does the page of the thing start?
  4. In the example on title and focus, remove the line with await nextTick(). Where is the focus after a change of page?

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.

Terminal window
npm install
npm test

Exercise 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
index.js
// 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
index.js
{
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
index.js
// Runs after every navigation and can no longer stop it
router.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">.

MistakeWhat 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

  1. What does route.params.pathMatch contain for the address /club-night/2026?
  2. What changes in the build when a route loads its component with import()?
  3. When does scrollBehavior get a saved position?
  4. Why does the app set the focus after a navigation but not on the first load?
Answers
  1. The array ["club-night", "2026"], with one entry for each segment of the address.
  2. Vite puts the component into a file of its own, which the browser only loads on the first visit to the route.
  3. Only for back and forward in the browser.
  4. 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.