Skip to main content

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-active and router-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.

src/router.ts
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 writtenWhat 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.

RouteSegment contents/users/users/
/users/:uid?One optional parameterMatches(not verified)
/users/:uid?:name?Two optional parametersDoes not matchMatches
/users/:uid?-:name?Two optional parameters plus a separatorDoes 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 writtenWhat is passed
trueroute.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.

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
onMountedOnce only. It does not run a second time
route.params.id in the templateFollows along and becomes 2
A value taken inside onMountedStays 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.

views/UserView.vue
<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.

Watch the declaration order

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.

views/SettingsView.vue
<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.

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

ClassCondition for being applied
router-link-activeThe 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-activeThat 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 specifiedExample
Per link<RouterLink active-class="is-open">
Router-widecreateRouter({ linkActiveClass: 'nav-on' })
Unspecifiedrouter-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.

BundlerHow it is written
Vite (Rollup)build.rollupOptions.output.manualChunks in vite.config.ts
webpackimport(/* 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 with app.use(router). createWebHistory() presupposes server configuration returning index.html for any URL not matching a static file, and the catch-all route only catches URLs that reached the app
  • Adding a name lets you navigate with { name: 'user', params: { id: 42 } }, which survives path changes
  • useRouter() is the side that acts and useRoute() 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 /users either, though it does match /users/
  • The props option cuts the dependency on route. Three modes: true (params directly), an object (static values), a function (queries and type conversion). Per-view specification is for routes with components:; writing it against a singular component: 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. onMounted runs only once, so pick refetching up with watch
  • The callback passed to watch is evaluated as a call argument, so declaring it with const after the watch gives a ReferenceError regardless of immediate
  • children gives nested routes, with the child going into the parent's <RouterView>. A child with an empty path is the default child matching the parent's path exactly. That link is exact-active only while that child is displayed, and navigating to a sibling leaves only active. Navigating to a sibling leaves the parent's element unchanged
  • router-link-active requires 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-active further demands that it is last in the match list and that the parameters match exactly. When the link points at a child with an empty path, the parent's record is used. While a child is displayed the parent's link stays active, and a link to /users/2 while /users/1 is displayed gets neither
  • Class names come from three levels — per link, router-wide, then the default — with the upper replacing the lower
  • Passing component a 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 webpackChunkName comment 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
Related references