03 · Vue Router Basics¶
A single-page app still needs URLs: people bookmark pages, share links and press the Back button. Vue Router maps URLs to components, keeps the address bar in sync with what's on screen, and does it without full page reloads. This lesson covers the core API; the next one covers guards, metadata, lazy loading and the file-based routing that's built into Vue Router 5.
Setup¶
If you chose --router when scaffolding, this is already in place:
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', name: 'home', component: HomeView },
{ path: '/about', name: 'about', component: () => import('@/views/AboutView.vue') },
],
})
export default router
Otherwise, npm install vue-router and add the two files yourself.
History modes¶
| Function | URLs look like | Server needs |
|---|---|---|
createWebHistory() |
/users/42 |
to serve index.html for every unknown path (Level 4 · 08) |
createWebHashHistory() |
/#/users/42 |
nothing — works on any static host |
createMemoryHistory() |
(not reflected in the address bar) | used for SSR and tests |
Use web history for real apps. Hash history is a fallback for hosts where you can't configure a catch-all rewrite; hash URLs are also less friendly for SEO.
RouterView and RouterLink¶
<template>
<header>
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink :to="{ name: 'users' }">Users</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
<RouterView /> renders the component matched by the current URL. <RouterLink> renders
an <a> with a real href (so middle-click, "copy link" and screen readers work) and
intercepts normal clicks to navigate without a reload.
The active link gets classes and ARIA for free. From a test with two links where the
current URL was /users/1:
<a aria-current="page" href="/users/1" class="router-link-active router-link-exact-active">u1</a>
<a href="/users/1/posts" class="">posts</a>
router-link-active applies when the link's route is part of the current match (including
parents); router-link-exact-active only when it's the exact route. Style those classes to
highlight the current section.
Dynamic routes¶
A segment starting with : is a param:
/users/42 matches, with route.params.id === '42' — params are always strings (or
arrays of strings for repeatable params). Convert them yourself.
Params as props¶
Reading useRoute().params inside a component ties the component to the router. Prefer
passing params as props:
<script setup lang="ts">
import { computed } from 'vue'
import { useFetch } from '@/composables/useFetch'
const { id } = defineProps<{ id: string }>()
const { data: user, loading } = useFetch<{ name: string }>(() => `/api/users/${id}`)
const numericId = computed(() => Number(id))
</script>
Now the component is reusable and trivially testable with mount(UserView, { props: { id: '1' } }).
props can also be an object (static props) or a function (route) => ({ ... }) for
converting and combining params and query values:
The same component is reused¶
This is the #1 routing surprise. Navigating from /users/1 to /users/2 does not
create a new UserView — both URLs match the same route, so Vue Router keeps the instance
and just updates its props. We logged setup and onMounted in a user component and
navigated from user 1 to user 2:
The heading updated to user 2, but setup ran only once. Any code in setup that read the
id once (a non-reactive fetch, a ref(props.id) copy) is now stale. That's why the
useFetch above takes a getter — it re-runs when id changes. If you truly need a
fresh component per param, put a key on the view: <RouterView :key="$route.fullPath" />
— at the cost of destroying and recreating it on every navigation.
Query strings and hashes¶
In a component, useRoute().query.page is '2' (a string), or an array if repeated. The
query is a great place for UI state you want shareable: filters, search, pagination, the
selected tab.
Nested routes¶
Real layouts nest: a user page with "Profile" and "Posts" tabs, each with its own URL.
Child routes render inside the parent's own <RouterView>:
{
path: '/users/:id',
component: UserLayout,
props: true,
children: [
{ path: '', name: 'user-profile', component: UserProfile }, // /users/42
{ path: 'posts', name: 'user-posts', component: UserPosts }, // /users/42/posts
],
}
<template>
<h1>User {{ id }}</h1>
<nav>
<RouterLink :to="{ name: 'user-profile', params: { id } }">Profile</RouterLink>
<RouterLink :to="{ name: 'user-posts', params: { id } }">Posts</RouterLink>
</nav>
<RouterView />
</template>
Child paths without a leading / are relative to the parent. The empty-path child renders
at the parent's own URL.
Programmatic navigation¶
import { useRouter } from 'vue-router'
const router = useRouter()
await router.push({ name: 'user', params: { id: 42 } }) // adds a history entry
await router.replace('/login') // replaces the current entry
router.back() // same as history.back()
push returns a promise that resolves when navigation finishes. It resolves to
undefined on success, or to a navigation failure if the navigation was aborted,
cancelled or redundant. Pushing the current URL again resolved to a failure whose string
form was:
It's resolved, not thrown — so await router.push() doesn't need a try/catch for these.
Use isNavigationFailure(result) when you need to know.
Named routes are more robust than string paths: rename a URL in one place and every
{ name: 'user' } link keeps working. router.resolve() shows what a location becomes:
router.resolve({ name: 'user', params: { id: 42 }, query: { tab: 'posts' } }).fullPath
// '/users/42?tab=posts'
Not-found pages¶
A catch-all route with a custom regex matches anything the other routes don't:
Visiting /nope/deep matched it, with params {"pathMatch":["nope","deep"]} — the
trailing * makes the param repeatable, so it's split into segments. Order doesn't matter
in Vue Router 4+: routes are ranked by specificity, so the catch-all loses to any more
specific route.
How It Actually Works¶
When you create the router, it compiles every route path into a matcher: a regular expression plus a score. Static segments score higher than params, params higher than custom regexes and wildcards. When a URL comes in, the router tries matchers in score order and takes the first match — which is why route order in the array rarely matters.
The current location is stored in router.currentRoute, a shallowRef. useRoute()
returns a reactive view of it; <RouterView> reads it inside its render function, so
every navigation re-renders the RouterViews. Each RouterView knows its depth (a
nested RouterView injects its parent's depth and adds one) and renders
currentRoute.matched[depth] — the matched record at its level. A nested URL like
/users/42/posts has matched = [userLayoutRecord, userPostsRecord]: depth 0 renders the
layout, depth 1 renders the posts.
When the new match at a depth has the same record as before, the RouterView produces a
vnode for the same component type, and Vue's renderer patches it in place instead of
remounting — that's the reuse you saw above. With props: true, the router passes
route.params as props when rendering, so the reused instance gets new props.
createWebHistory wraps the browser's History API: router.push validates the navigation
(running guards, next lesson), then calls history.pushState, then updates currentRoute.
Pressing Back fires popstate, which the router turns into a navigation through the same
pipeline.
Common mistakes¶
- Using
<a href>for internal links — it triggers a full page reload and loses in-memory state. Use<RouterLink>. - Reading params once in
setupand missing param changes on the same route. - Forgetting params are strings.
params.id === 42is always false. - Using
useRoute()outsidesetup— like other composables, call it at the top level and keep the result. - Deploying web history without a server fallback — refreshing
/users/42returns 404 from the server. Level 4 · 08 covers the fix for common hosts. - Using
router.pushfor external URLs — usewindow.location.hrefor a plain<a>.
Exercise¶
Create a small "people directory" with the JSONPlaceholder API
(https://jsonplaceholder.typicode.com/users) or a local JSON file:
- Routes:
/(home),/users(list),/users/:idwith children''(profile) andposts(that user's posts from/posts?userId=:id), and a not-found page. - Pass
idas a prop converted to a number with a props function. - Add "Previous" / "Next" links on the profile that go to
id - 1/id + 1. Verify the data changes when you click them — and then break it deliberately by fetching with a non-reactive value, to see the reuse problem for yourself. - Add a
?sort=name|emailquery to the list page, bound to a<select>, so that the sort survives a reload and a shared link.