Skip to content
TutorialRouting

Add Vue Router to a Vite app in ten minutes

A Vite app starts as one page. Vue Router 4.5 turns it into many, without a full reload between them. This is the whole setup, file by file.

vuejs.school team25 Mar 20256 min readVue Router 4.5 · Vue 3.5

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.

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

src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')
src/App.vue
<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:

src/App.vueBreaks
<a href="/about">About</a>
src/App.vueWorks
<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.

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-active is set when the link points to the current route itself: on /users/42, a link to /users/42 gets it and a link to /users/7 does not.
  • router-link-active is set on that link too, and also on links to parent routes when routes are nested with children. A link to /users is active on /users/42 only if the user page is a child route of /users.

Style whichever fits:

src/assets/main.css
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:

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

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

src/router/index.ts
{ 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 /about asks the server for a file called about, 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/:

public/_redirects
/*  /index.html  200

On nginx, inside the location / block:

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

Check it stuck3 questions from the Your first router quiz.
Practice now
Was this helpful?

Keep reading