Vue Router basics — mapping URLs to components
The fourteen chapters so far were about what happens within one screen. From this chapter we move to switching between screens. Vue Router holds a mapping from URLs to components and, when the URL changes, swaps the corresponding component into the position of <RouterView>.
The version used in this chapter is vue-router 5.2.0.
What you'll learn in this chapter
- How to write route definitions and how URLs are matched
- How to pass values to a component without using
route.params - Why a component is not rebuilt when only a parameter changes
- The difference in conditions for
router-link-activeandrouter-link-exact-active - The current way to write lazy loading and code splitting
Building the mapping
The convention is to keep the router in one file.
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import HomeView from './views/HomeView.vue'
const routes: RouteRecordRaw[] = [
{ path: '/', name: 'home', component: HomeView },
{
path: '/users/:id',
name: 'user',
// a dynamic import lets the bundler split off a chunk
component: () => import('./views/UserView.vue'),
props: true,
},
{
path: '/settings',
component: () => import('./views/SettingsView.vue'),
children: [
{ path: '', name: 'settings', component: () => import('./views/ProfileTab.vue') },
{ path: 'security', name: 'security', component: () => import('./views/SecurityTab.vue') },
],
},
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('./views/NotFoundView.vue') },
]
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
Register it with createApp(App).use(router).mount('#app') and <RouterView> and <RouterLink> become usable anywhere.
createWebHistory() uses the History API, so no # appears in the URL. It does, however, require configuration on the serving side (Vite's dev server takes care of it by default, so you will not notice during development). Opening /users/42 directly or reloading sends the request to the server, so without the configuration the official guide describes — return index.html for any URL that does not match a static file — you get the server's 404. The catch-all route above only catches URLs that reached the app. Deployment is covered in chapter 20.
Adding a name lets you navigate by name rather than by path string. Changing a path then means editing one place.
<RouterLink :to="{ name: 'user', params: { id: 42 } }">User 42</RouterLink>
For navigating from code, use useRouter(). The name resembles useRoute(), but useRouter() is the side that acts and useRoute() is the side that reads the current state.
const router = useRouter()
const route = useRoute()
router.push({ name: 'user', params: { id: 42 } }) // add one history entry
router.replace('/login') // replace the current entry
router.back() // go back one
How URLs are matched
A part written as :id becomes a parameter, readable through route.params.id. Two other notations come up often.
| How it is written | What it matches |
|---|---|
/users/:id | /users/42 |
/users/:userId? | Both /users and /users/42 |
/:pathMatch(.*)* | URLs that matched nothing else (for an in-app 404) |
? means "zero or one". There is a condition, though. Unless the segment contains nothing but a single optional parameter, it does not match a URL without a trailing slash.
Measured, it comes out like this.
| Route | Segment contents | /users | /users/ |
|---|---|---|---|
/users/:uid? | One optional parameter | Matches | (not verified) |
/users/:uid?:name? | Two optional parameters | Does not match | Matches |
/users/:uid?-:name? | Two optional parameters plus a separator | Does not match | (not verified; /users/- does match) |
The presence of a separator is irrelevant. The second row does not match even without one. Placing two optional parameters is already enough to stop the segment being omissible. To make it omissible, put exactly one parameter in the segment.
Passing values to components
Using useRoute() makes the component depend on the shape of the URL. Tests then have to assemble a router too. The props option cuts that dependency.
There are three modes, and each passed values as expected when measured.
How props is written | What is passed |
|---|---|
true | route.params becomes the props directly |
An object ({ label: 'fixed' }) | The written values go in as-is. For static values |
A function (route => ({ query: route.query.q })) | The returned object becomes the props. For queries and type conversion |
With named views (a route holding several views through components:), specify per view (props: { default: true, sidebar: false }). Writing the same against a singular component: is interpreted as object mode, and a prop literally named default gets passed through.
Function mode comes with a caveat. The official guide states that it is evaluated only on route change, so do not give it state. To build props from component state, interpose a wrapping component.
Navigation where only a parameter changes
Moving from /users/1 to /users/2 changes the URL but the corresponding component is the same. Vue Router does not rebuild it here. Measured, it comes out like this.
| What you look at | /users/1 → /users/2 |
|---|---|
onMounted | Once only. It does not run a second time |
route.params.id in the template | Follows along and becomes 2 |
A value taken inside onMounted | Stays stale |
So what is written directly in the template updates on its own, while work done once at mount is left behind. To refetch data, pick it up with watch.
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{ id: string }>()
const user = ref<{ name: string } | null>(null)
async function load(id: string) {
const res = await fetch(`/api/users/${id}`)
user.value = await res.json()
}
// the component is not rebuilt when only a parameter changes, so pick it up with watch
watch(() => props.id, load, { immediate: true })
</script>
<template>
<section>
<h1>{{ user?.name ?? 'Loading' }}</h1>
</section>
</template>
With { immediate: true } it runs the first time too, so onMounted is unnecessary. The watch from chapter 5 works here directly.
The load in watch(() => props.id, load) is evaluated as a call argument. Declare load with const after the watch and it is uninitialised at that point, giving a ReferenceError. It happens whether or not immediate is set. Put the watch after everything it is passed has been declared.
Sharing a layout
Writing children gives nested routes. Put a <RouterView> inside the parent component and the child goes there.
<template>
<div class="settings">
<nav>
<RouterLink :to="{ name: 'settings' }">Profile</RouterLink>
<RouterLink :to="{ name: 'security' }">Security</RouterLink>
</nav>
<RouterView />
</div>
</template>
Making a child's path an empty string gives the default child, matching the parent's path exactly (/settings). Measured, /settings inserts the default child, and navigating to /settings/security leaves the parent's element unchanged while only the child swaps.
While the default child is displayed, the link to { name: 'settings' } becomes exact-active. A child with an empty path matches the same URL as its parent, so it counts as the deepest match for link checking too.
When a link counts as "where you are"
<RouterLink> applies two classes depending on the current location (not applied when you use custom and render the contents yourself through v-slot). The checks differ as follows.
| Class | Condition for being applied |
|---|---|
router-link-active | The deepest record of the route the link points at is in the current route's match list, and the current parameters include the link's parameters |
router-link-exact-active | That record is last in the current match list and the parameters match exactly |
There is one exception when no deepest record is found. When the link points at a "child with the same path as its parent" (a child with an empty path), the check uses the parent's record. That is the route by which a link to { name: 'settings' } gets active while /settings/security is displayed. If the current location is itself such a child, though, the exception is not used.
So while /settings/security is displayed, the link to /settings is active but not exact-active. Use that difference to highlight the parent side of breadcrumbs and tabs.
The parameter condition matters too. While /users/1 is displayed, a link to /users/2 gets neither class. The matched records are the same, but the current parameters do not include the link's. Measured, only the link to /users/1 got active.
Class names are decided at three levels.
| Where it is specified | Example |
|---|---|
| Per link | <RouterLink active-class="is-open"> |
| Router-wide | createRouter({ linkActiveClass: 'nav-on' }) |
| Unspecified | router-link-active |
The upper replaces the lower. Pass active-class and router-link-active is not applied. With utility-class CSS, replacing the global default is the easier route.
Lazy loading and code splitting
Pass component a function returning a Promise of a component and it is loaded only on the first visit to that route. Measured, it was called once on the first visit and not again after leaving and coming back (a successful result is cached).
What it returns is a Promise. Write a function returning the component itself and the development build emits a diagnostic and rescues it. That rescue sits inside a development-build branch, so the same code does not get through in a production build (the production behaviour comes from reading the implementation and was not verified by running it). A functional component placed on a route is indistinguishable from lazy loading, so the official guide advises giving it a displayName.
{ path: '/users/:id', component: () => import('./views/UserView.vue') }
The official guide states that using a dynamic import on every route is the baseline. With Vite or webpack, that alone gets you code splitting.
How you group chunks differs by bundler.
| Bundler | How it is written |
|---|---|
| Vite (Rollup) | build.rollupOptions.output.manualChunks in vite.config.ts |
| webpack | import(/* webpackChunkName: "group-user" */ './UserView.vue') |
The webpackChunkName comment has no effect under Vite. The official guide splits Vite and webpack into separate sections and carries this comment only on the webpack side. Projects created with create-vue are Vite, so it is not available there.
One more easily confused point. Route lazy loading is a different mechanism from the async components in chapter 13. As seen there, route lazy loading does not currently trigger <Suspense>. The official guide states explicitly not to use async components for route components. Pass a plain function to the route and use async components inside it.
Summary
- Create the router with
createRouter({ history, routes })and register it withapp.use(router).createWebHistory()presupposes server configuration returningindex.htmlfor any URL not matching a static file, and the catch-all route only catches URLs that reached the app - Adding a
namelets you navigate with{ name: 'user', params: { id: 42 } }, which survives path changes useRouter()is the side that acts anduseRoute()is the side that reads the current state:uid?means "zero or one". But unless the segment contains nothing but a single optional parameter, it does not match a URL without a trailing slash. The presence of a separator is irrelevant:/users/:uid?:name?does not match/userseither, though it does match/users/- The
propsoption cuts the dependency onroute. Three modes:true(params directly), an object (static values), a function (queries and type conversion). Per-view specification is for routes withcomponents:; writing it against a singularcomponent:gives object mode - Function mode is evaluated only on route change. To build props from component state, interpose a wrapping component
- Navigation where only a parameter changes does not rebuild the component.
onMountedruns only once, so pick refetching up withwatch - The callback passed to
watchis evaluated as a call argument, so declaring it withconstafter thewatchgives aReferenceErrorregardless ofimmediate childrengives nested routes, with the child going into the parent's<RouterView>. A child with an emptypathis the default child matching the parent's path exactly. That link isexact-activeonly while that child is displayed, and navigating to a sibling leaves onlyactive. Navigating to a sibling leaves the parent's element unchangedrouter-link-activerequires both that the link's deepest record is in the current match list and that the current parameters include the link's.router-link-exact-activefurther demands that it is last in the match list and that the parameters match exactly. When the link points at a child with an emptypath, the parent's record is used. While a child is displayed the parent's link stays active, and a link to/users/2while/users/1is displayed gets neither- Class names come from three levels — per link, router-wide, then the default — with the upper replacing the lower
- Passing
componenta function returning a Promise of a component loads it only on the first visit, caching the successful result thereafter. A function returning something other than a Promise is rescued only in development builds - Grouping chunks differs by bundler. The
webpackChunkNamecomment has no effect under Vite - Route lazy loading is a different mechanism from async components. Do not use an async component as a route component
- Vue Router — Dynamic Route Matching — how to write parameters
- Vue Router — Routes' Matching Syntax — the
?and regular-expression notation - Vue Router — Passing Props to Route Components — the three
propsmodes - Vue Router — Lazy Loading Routes — dynamic imports and grouping chunks
What to read next
- Navigation guards — blocking and redirecting navigation
- Pinia — state that spans screens