A Vue app created with npm create vue@latest is a single HTML page. The browser loads it once, and from then on Vue swaps components in and out. A router connects that swapping to the address bar: /about shows one component, /users/42 shows another, the back button works, and a link can be bookmarked.
Six short steps add Vue Router 4.5 to such a project, and one server rule keeps a refresh on /about working after you deploy. If you ticked "Router (SPA development)" when creating the project, you already have most of these files; read on to learn what they do.
Step 1: install vue-router
npm install vue-router@4
Vue Router 4 is the version for Vue 3. The @4 makes that explicit: version 3, which you will meet in older tutorials, only works with Vue 2.
While you are at it, create a src/views/ folder for the components that represent whole pages. Nothing enforces that folder, but it keeps page components apart from the small reusable ones in src/components/.
Step 2: create the router and its routes
Create src/router/index.ts. It exports one router built from two things: a history mode, which decides how the URL is written, and a routes array, which maps paths to components.
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
createWebHistory() gives you clean URLs like /about. import.meta.env.BASE_URL is the base path Vite was configured with, / by default, so the router and the build agree if you ever deploy under a sub-folder. Each route has a path, a name you can navigate by instead of typing the path again, and a component.
Notice the two ways of giving the component. HomeView is imported at the top, so it is in the main bundle and ready at once; the home page should be. AboutView is given as () => import(...), a function that returns a promise. Vite puts that component in its own file and the browser downloads it the first time someone visits /about. This is lazy loading, and it is the right default for every route that is not the first screen.
Step 3: connect the router to the app
Two edits: src/main.ts tells the app about the router, and src/App.vue gives the router a place to render.
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
<main>
<RouterView />
</main>
</template>
<RouterView /> is the hole the matched component renders into. <RouterLink> renders a real <a> tag, so the link works for keyboards, screen readers and middle clicks, but it intercepts the click and asks the router to change the view instead of letting the browser reload the page. Using a plain <a> for a link inside the app is a common beginner mistake:
<a href="/about">About</a>
<RouterLink to="/about">About</RouterLink>
The plain <a> works, in the sense that the about page appears. It also throws away the whole app and starts it again: every store resets, every pending request is lost, and the page flashes. Use <RouterLink> for anything inside the app and <a> only for links that leave it.
Step 4: style the active link
The router adds two classes to a RouterLink on its own. It decides them by comparing routes (the entries in your routes array), not the text of the paths:
router-link-exact-activeis set when the link points to the current route itself: on/users/42, a link to/users/42gets it and a link to/users/7does not.router-link-activeis set on that link too, and also on links to parent routes when routes are nested withchildren. A link to/usersis active on/users/42only if the user page is a child route of/users.
Style whichever fits:
nav a.router-link-exact-active {
font-weight: 700;
text-decoration: underline;
}
If you would rather use your own class names, for example to match a CSS framework, pass linkActiveClass and linkExactActiveClass to createRouter() next to history and routes.
Step 5: read route params and navigate from code
A route can carry parameters. /users/:id matches /users/42 and exposes id. Add this route to the routes array:
{ path: '/users/:id', name: 'user', component: () => import('../views/UserView.vue') },
Inside a component you reach the current route with useRoute() and you move to another one with useRouter(). The names are close, so keep them straight: the route is where you are, the router is how you move.
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const id = computed(() => route.params.id as string)
function goHome() {
router.push({ name: 'home' })
}
</script>
<template>
<h1>User {{ id }}</h1>
<button type="button" @click="goHome">Back to home</button>
</template>
route.params.id is typed as string | string[], because a repeatable parameter can match several segments. This route has one segment, so the cast to string is honest.
The computed matters. When the user goes from /users/1 to /users/2, the router reuses the same UserView instance rather than creating a new one, so a value copied once at setup would still say 1. A computed re-reads the param whenever it changes.
The same applies to fetching. If you load the user in onMounted, it runs once, so the page keeps showing user 1 after a move to /users/2. Put the fetch in a watch on () => route.params.id instead, with immediate: true, and it runs again for every new id.
router.push() accepts a path string or a location object. The object form with name survives a change to the path later, which is why the routes have names.
Call both composables while the component is being set up: at the top level of <script setup>, as here, or inside your own composable called from there. Called later, in a click handler or a setTimeout callback, they return undefined, and in development Vue warns that inject() can only be used inside setup().
Step 6: add a 404 page
A URL that matches no route renders nothing: <RouterView /> stays empty, and the only sign is a "No match found for location" warning in the browser console during development. Give it a page by adding a catch-all as the last entry in the routes array:
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('../views/NotFoundView.vue') },
The pattern reads as: a parameter called pathMatch, matching anything (.*), repeated across any number of segments (the trailing *). In NotFoundView.vue, route.params.pathMatch holds the segments the user typed, which is handy for a "did you mean" message.
Choose web history and add a server fallback
The router can write URLs in two ways:
createWebHashHistory()writes/#/about. The part after#never reaches the server, so any static host serves the app for any URL with no configuration. The cost is uglier URLs and worse treatment by search engines and link previews.createWebHistory()writes/about, which is what users expect. But a refresh on/aboutasks the server for a file calledabout, and the server has none.
Use web history, and fix the server: for any path it has no file for, it should answer with index.html. Vite's dev server already does this, so you will only see the problem after deploying. Most static hosts take that fallback as a one-line rule. On Netlify, a _redirects file in public/:
/* /index.html 200
On nginx, inside the location / block:
try_files $uri $uri/ /index.html;
Vercel takes the same rule as a rewrites entry in vercel.json. GitHub Pages has no such setting, which makes hash history the pragmatic choice there.
Once the server always answers with index.html, the app boots, the router reads the address bar, and /about shows the about page. Combined with the catch-all route from step 6, a genuinely wrong URL now shows your 404 page instead of the host's. The server itself answers it with a 200 status, not a 404, so only your 404 page says the page is missing.