Vue Router Essentials
# Installation and setup
Install vue-router like this.
1npm i vue-router
Start with the following folder and file structure.
12345678├─src/ │ ├─routes/ │ │ ├─pages/ │ │ │ ├─AboutPage.vue │ │ │ └─HomePage.vue │ │ └─index.ts | ├─App.vue │ └─main.ts
Add the page components you will use to the /src/routes/pages folder.
To keep things simple, let's start with a Home page and an About page.
123<template> <h1>Home page!</h1> </template>
123<template> <h1>About page!</h1> </template>
Once you have added the pages to show, configure the project's router in the /src/routes/index.ts file.
Create a router with the createRouter function.
Set the routing mode in the history option, and pass route objects (route info) as an array to the routes option.
In each route object, the path property sets the access path, and the component property sets the component to show when the path matches.
So in the following example, users can visit the / and /about addresses.
Then export the router object (router) created with createRouter so it can be used elsewhere.
12345678910111213141516171819import { createRouter, createWebHistory } from 'vue-router' import HomePage from './pages/HomePage.vue' import AboutPage from './pages/AboutPage.vue' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: HomePage }, { path: '/about', component: AboutPage } ] }) export default router
Now go to /src/main.ts, import the router object you created, and register it as a plugin of the project.
1234567import { createApp } from 'vue' import App from './App.vue' import router from './routes' createApp(App) .use(router) .mount('#app')
Finally, in the project's top-level component (/src/App.vue), use the <RouterView /> component to mark where each page should render for the path the user visits.
1234567<script setup lang="ts"> import { RouterView } from 'vue-router' </script> <template> <RouterView /> </template>
# Layouts
1234567891011121314├─src/ │ ├─components/ │ │ └─TheHeader.vue │ ├─routes/ │ │ ├─layouts/ │ │ │ ├─DefaultLayout.vue │ │ │ ├─EmptyLayout.vue │ │ │ └─LayoutProvider.vue │ │ ├─pages/ │ │ │ ├─AboutPage.vue │ │ │ └─HomePage.vue │ │ └─index.ts | ├─App.vue │ └─main.ts
Let's make a header with navigation buttons for moving between pages, and show it on every page that needs it.
First, create the <TheHeader> component like this.
12345678<template> <header> <nav> <a href="/">Home</a> <a href="/about">About</a> </nav> </header> </template>
If you add structures shared by most pages, such as a header or footer, directly to each page component, they re-render needlessly every time the page changes.
So you should provide a separate layout structure so the shared parts don't render again.
Add a default layout (DefaultLayout.vue) like this to show the header we just made.
Then use the <slot> component to mark where the page should render for the visited path.
12345678<script setup lang="ts"> import TheHeader from '@/components/TheHeader.vue' </script> <template> <TheHeader /> <slot /> </template>
Some pages may not have a shared layout such as a header or footer, so let's also add an empty layout (EmptyLayout.vue) like this.
123<template> <slot /> </template>
You choose which layout a page uses in the route object's meta.layout property, like this.
In this walkthrough, though, we only use the default layout, so we won't set it.
12345678910111213141516171819202122232425import { createRouter, createWebHistory } from 'vue-router' import HomePage from './pages/HomePage.vue' import AboutPage from './pages/AboutPage.vue' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: HomePage, meta: { layout: 'Default' } }, { path: '/about', component: AboutPage, meta: { layout: 'Empty' } } ] }) export default router
Now let's add a layout provider (LayoutProvider.vue) so each page renders inside a layout.
Define the layouts the project uses as a list (layouts), and use a dynamic component (<Component>) to render the layout that matches the meta.layout value.
A component can get the current route info by calling the useRoute() composable, and we make it fall back to the default layout (DefaultLayout.vue) when there is no meta.layout property.
Then, if you pass the page's render location (<RouterView />) as a child of the dynamic component, the page renders where you put <slot> in each layout earlier.
To make sure meta.layout never gets a wrong name other than the defined layout names (Default or Empty), you can extend the router interface (RouteMeta) and manage it with safe types.
12345678910111213141516171819202122232425262728<script setup lang="ts"> import { RouterView, useRoute } from 'vue-router' import Default from './DefaultLayout.vue' import Empty from './EmptyLayout.vue' // Extend the router interface declare module 'vue-router' { interface RouteMeta { layout?: keyof typeof layouts } } // List of layouts to use const layouts = { Default, Empty } as const // Get the current route object (info) const route = useRoute() </script> <template> <!-- Render the layout dynamically, falling back to the default layout --> <Component :is="layouts[route.meta.layout || 'Default']"> <RouterView /> </Component> </template>
Finally, replace <RouterView /> in the top-level component with the layout provider component (<LayoutProvider>).
The page render location has moved inside the layout provider, so every page now renders through a layout.
1234567<script setup lang="ts"> import LayoutProvider from '@/routes/layouts/LayoutProvider.vue' </script> <template> <LayoutProvider /> </template>
When you apply the same layout to several pages, you can set it once instead of setting it on every route.
This works because route.meta is the merged result of the meta of every route object the current path passes through.
So you can group the pages under a route that has only a children property (Nested routes) and no component property, and write meta.layout once on that parent.
If a child route sets it again, the child's value wins.
123456789101112131415161718192021routes: [ { path: '/admin', meta: { layout: 'Empty' }, children: [ { path: '', component: AdminHomePage }, { path: 'users', component: AdminUsersPage, meta: { layout: 'Default' } } ] } ]
A parent route without a component property adds nothing to the screen and only groups the paths and meta.
So the child pages still render at the layout provider's <RouterView /> location, just as they do now.
# Scroll restoration
When users go back or forward, you can restore the page's scroll position, or move a new page's scroll position to the top, to give them a better navigation experience.
You can handle the scroll position in the scrollBehavior option of the createRouter function.savedPosition holds a saved position only when moving with back/forward, and is null otherwise.
1234567891011121314// ... const router = createRouter({ history: createWebHistory(), scrollBehavior(_to, _from, savedPosition) { if (savedPosition) return savedPosition return { top: 0, left: 0 } }, routes: [ // ... ] }) export default router
The first two parameter names start with an underscore (_) because the TypeScript configuration generated by Vite turns on the noUnusedParameters option.
If you leave unused parameters as they are, the development server still works, but the npm run build command fails with an error.
# Navigation
# Component approach
Navigating with the <a> elements in the header we wrote earlier always loads the whole page.
If you use the <RouterLink> component instead, only the parts that need to change are updated when you navigate, which gives a better user experience.
The <RouterLink> component takes the destination path in the to attribute instead of href.
Active classes (router-link-active, router-link-exact-active) are added automatically, depending on whether the path in to matches the current path.
If you style the navigation based on the active classes, users can see more clearly which page they are on.
Update the <TheHeader> component like this.
1234567891011121314151617181920212223<script setup lang="ts"> import { RouterLink } from 'vue-router' </script> <template> <header> <nav> <RouterLink to="/">Home</RouterLink> <RouterLink to="/about">About</RouterLink> </nav> </header> </template> <style scoped> nav { display: flex; gap: 10px; } .router-link-exact-active { font-weight: bold; color: red; } </style>
The router-link-active class is added when the given path is part of the current path, and the router-link-exact-active class is added only when the given path and the current path match exactly.
Given path (to) |
Current path (URL) | router-link-active |
router-link-exact-active |
|---|---|---|---|
/ |
/ |
✅ | ✅ |
/ |
/about |
❌ | ❌ |
/about |
/about |
✅ | ✅ |
/movies |
/movies |
✅ | ✅ |
/movies |
/movies/abc123 |
✅ | ❌ |
/movies/abc123 |
/movies/abc123 |
✅ | ✅ |
Whether a link is active isn't decided by whether the path strings start the same way, but by whether the route object of the given path is in the list of route objects the current path passes through.
So the /movies and /movies/abc123 rows in the table assume the two routes are parent and child (Nested routes).
If you put both routes side by side at the top level, the /movies link doesn't get the active class on /movies/abc123.
In the to attribute, you usually write the whole path as a string.
Or you can pass an object with the required base path (path) and, optionally, a query string (query) and a hash fragment (hash).
You can also pass an object with the required route name (name) and, optionally, dynamic params (params), a query string, and a hash.
123456789101112131415161718192021222324<template> <RouterLink :to="`/movies/${movieId}?plot=full#title`"> Go to page </RouterLink> <RouterLink :to="{ path: `/movies/${movieId}`, query: { plot: 'full' }, hash: '#title' }"> Go to page </RouterLink> <RouterLink :to="{ name: 'MovieDetails', params: { movieId }, query: { plot: 'full' }, hash: '#title' }"> Go to page </RouterLink> </template>
You can optionally write a route name in the route object's name property, and it must be unique.
If you manage routes by name, you need fewer code changes when a path changes, and you avoid writing a wrong path by mistake.
1234567891011121314151617181920212223242526272829303132333435363738394041// ... const router = createRouter({ // ... routes: [ { name: 'Home', path: '/', component: HomePage }, { name: 'About', path: '/about', component: AboutPage }, { name: 'SignIn', path: '/signin', component: SignInPage }, { name: 'Movies', path: '/movies', component: MoviesPage, children: [ { name: 'MovieDetails', path: ':movieId', component: MovieDetailsPage } ] }, { name: 'NotFound', path: '/:pathMatch(.*)*', component: NotFoundPage } ] }) export default router
# Programmatic approach
Instead of using the <RouterLink> component, you can also navigate programmatically.
Calling the useRouter() composable gives you the router instance, which has several methods for moving between pages.
push: Go to a new pagereplace: Go to a new page, replacing the current pageback: Go backforward: Go forwardgo: Go back or forward by the given number of steps
1234567891011121314<script setup lang="ts"> import { useRouter } from 'vue-router' const router = useRouter() </script> <template> <button @click="router.push('/')">Go to page</button> <button @click="router.replace('/about')">Go to page (can't go back)</button> <button @click="router.back()">Go back</button> <button @click="router.forward()">Go forward</button> <button @click="router.go(-1)">Go back</button> <button @click="router.go(1)">Go forward</button> </template>
Just like the to attribute of the <RouterLink> component we saw earlier, the push and replace methods accept the path as a string or an object.
1234567891011121314151617181920function onlyString(movieId: string) { router.push(`/movies/${movieId}?plot=full#title`) } function pathObject(movieId: string) { router.push({ path: `/movies/${movieId}`, query: { plot: 'full' }, hash: '#title' }) } function nameObject(movieId: string) { router.push({ name: 'MovieDetails', params: { movieId }, query: { plot: 'full' }, hash: '#title' }) }
# Dynamic route matching
Dynamic routes are a handy feature that lets you use part of the URL like a variable.
With them, a single route component can manage many pages dynamically, which greatly improves reusability and flexibility.
For example, instead of making a separate page for every movie's details, you can show the details of any movie with one component.
Let's apply a dynamic route as follows.
123456789101112131415├─src/ │ ├─components/ │ │ └─TheHeader.vue │ ├─routes/ │ │ ├─layouts/ │ │ │ ├─DefaultLayout.vue │ │ │ ├─EmptyLayout.vue │ │ │ └─LayoutProvider.vue │ │ ├─pages/ │ │ │ ├─AboutPage.vue │ │ │ ├─HomePage.vue │ │ │ └─MovieDetailsPage.vue │ │ └─index.ts | ├─App.vue │ └─main.ts
First, write the page that shows movie details (MovieDetailsPage.vue).
Get the dynamic path value named movieId from the params property of the route info returned by the useRoute() composable.
Then use this movieId to fetch the movie info with an API request and display it.
123456789101112131415161718192021222324252627282930313233<script setup lang="ts"> import { ref, watch } from 'vue' import { useRoute } from 'vue-router' interface Movie { imdbID: string Title: string Poster: string } const route = useRoute() const movie = ref<Movie | null>(null) async function fetchMovieDetails(movieId: string) { const res = await fetch(`https://omdbapi.com/?apikey=7035c60c&i=${movieId}`) movie.value = await res.json() } watch( () => route.params.movieId as string, movieId => fetchMovieDetails(movieId), { immediate: true } // Also called once on the first visit. ) </script> <template> <template v-if="movie"> <h1>{{ movie.Title }}</h1> <img :src="movie.Poster" :alt="movie.Title" /> </template> </template>
Now that the movie details page is ready, set its access path.
In the route object's path property, use the : symbol, as in '/movies/:movieId', to define a variable (movieId) that matches the path dynamically.
12345678910111213141516// ... import MovieDetailsPage from './pages/MovieDetailsPage.vue' const router = createRouter({ // ... routes: [ // ... { name: 'MovieDetails', path: '/movies/:movieId', component: MovieDetailsPage } ] }) export default router
Add a navigation button to the header so users can go to the new page.
In the following example, tt4154796 in the path is the ID of the movie 'Avengers: Endgame (2019)'.
12345678910111213<script setup lang="ts"> import { RouterLink } from 'vue-router' </script> <template> <header> <nav> <RouterLink to="/">Home</RouterLink> <RouterLink to="/about">About</RouterLink> <RouterLink to="/movies/tt4154796">Movie(Avengers: Endgame)</RouterLink> </nav> </header> </template>
# Query strings
You get a query string attached after the path, such as ?plot=full&page=2, from the query property of the route info.
Unlike dynamic routes, you don't need to define it in the route object in advance, and you can read it on any path.
123456789<script setup lang="ts"> import { useRoute } from 'vue-router' const route = useRoute() // When visiting /movies/tt4154796?plot=full&page=2 console.log(route.query.plot) // 'full' console.log(route.query.page) // '2' </script>
The values are part of the address string, so if you need a number, you have to convert it yourself.
Also, a value can be empty or the same name can repeat, so the type is string | null or an array of it.
| Address | route.query.plot |
|---|---|
?plot=full |
'full' |
?plot= |
'' |
?plot |
null |
?plot=full&plot=short |
['full', 'short'] |
Like dynamic routes, navigation that changes only the query string also reuses the same component.
If you need to do something whenever the value changes, watch it with watch.
123456watch( () => route.query.plot, plot => { console.log(plot) } )
Conversely, to navigate with a query string, as we saw in Navigation, pass an object that includes a query property to the to attribute of the <RouterLink> component or to the router.push method.
If you handle query strings whose values change often, such as search terms or filters, and you don't want to leave history entries, add the replace attribute to the <RouterLink> component or use the replace method.
123456789101112131415161718<script setup lang="ts"> import { useRoute, useRouter } from 'vue-router' const route = useRoute() const router = useRouter() function updatePlot(plot: string) { router.replace({ query: { ...route.query, plot } }) } </script> <template> <RouterLink :to="{ query: { plot: 'short' } }" replace> Short plot </RouterLink> </template>
If you leave out the path and pass only query, only the query string changes while the current path stays the same.
In this case, the existing query string is replaced entirely, so spread route.query and pass it along to keep the values you need.
123456router.replace({ query: { ...route.query, plot: 'short' } })
# Nested routes
A route component can contain other route components.
In the example below, we make a page where you can search for movies, and selecting a search result shows the movie details.
This time, though, the movie details appear as a modal instead of a separate page.
12345678910111213141516├─src/ │ ├─components/ │ │ └─TheHeader.vue │ ├─routes/ │ │ ├─layouts/ │ │ │ ├─DefaultLayout.vue │ │ │ ├─EmptyLayout.vue │ │ │ └─LayoutProvider.vue │ │ ├─pages/ │ │ │ ├─AboutPage.vue │ │ │ ├─HomePage.vue │ │ │ ├─MovieDetailsPage.vue │ │ │ └─MoviesPage.vue │ │ └─index.ts | ├─App.vue │ └─main.ts
First, write the movie search page (MoviesPage.vue).
When you select a movie from the search results, it moves to the movie details page.
Then use the <RouterView /> component to apply a nested route, so the movie details page appears as part of the search page.
123456789101112131415161718192021222324252627282930313233343536373839404142<script setup lang="ts"> import { ref } from 'vue' import { RouterLink } from 'vue-router' interface Movie { imdbID: string Title: string } const searchTitle = ref('') const movies = ref<Movie[]>([]) async function fetchMovies() { const res = await fetch( `https://omdbapi.com/?apikey=7035c60c&s=${searchTitle.value}` ) const { Search } = await res.json() movies.value = Search } </script> <template> <div> <h1>Movies page!</h1> <div> <input v-model="searchTitle" @keydown.enter="fetchMovies" /> <button @click="fetchMovies">Search</button> </div> <ul> <li v-for="movie in movies" :key="movie.Title"> <RouterLink :to="`/movies/${movie.imdbID}`"> {{ movie.Title }} </RouterLink> </li> </ul> <RouterView /> </div> </template>
Register the movie search page as a route object.
Move the route object of the movie details page we made earlier into the search page's children property to apply a nested route, and change its path from /movies/:movieId to :movieId.
The parent route (Movies) already has the path /movies, so the child route (MovieDetails) can leave out /movies and write only :movieId.
Now, as described above, the movie details page renders at the <RouterView /> component in the movie search page.
123456789101112131415161718192021222324// ... import MoviesPage from './pages/MoviesPage.vue' import MovieDetailsPage from './pages/MovieDetailsPage.vue' const router = createRouter({ history: createWebHistory(), routes: [ // ... { name: 'Movies', path: '/movies', component: MoviesPage, children: [ { name: 'MovieDetails', path: ':movieId', component: MovieDetailsPage } ] } ] }) export default router
Now let's show the movie details page as a modal.
Add the element structure for the modal, along with its styles.
Then add a closeModal function so that clicking the overlay moves to the parent path (/movies) and closes the modal.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859<script setup lang="ts"> // ... import { useRouter } from 'vue-router' // ... const router = useRouter() function closeModal() { // Leaving the child route closes the modal. router.push('/movies') } </script> <template> <div class="modal"> <div class="overlay" @click="closeModal" /> <div class="content"> <template v-if="movie"> <h1>{{ movie.Title }}</h1> <img :src="movie.Poster" :alt="movie.Title" /> </template> </div> </div> </template> <style scoped> .modal { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center; } .overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); cursor: pointer; } .content { position: relative; max-width: 500px; padding: 20px 30px; border-radius: 10px; box-shadow: 0 10px 10px rgba(0, 0, 0, 0.1); background-color: white; } </style>
Everything is ready, so let's add a navigation button that goes to the movie search page and test it.
1234567891011121314<!-- ... --> <template> <header> <nav> <RouterLink to="/">Home</RouterLink> <RouterLink to="/about">About</RouterLink> <RouterLink to="/movies">Movies</RouterLink> <RouterLink to="/movies/tt4154796">Movie(Avengers: Endgame)</RouterLink> </nav> </header> </template> <!-- ... -->
# Not found page
1234567891011121314151617├─src/ │ ├─components/ │ │ └─TheHeader.vue │ ├─routes/ │ │ ├─layouts/ │ │ │ ├─DefaultLayout.vue │ │ │ ├─EmptyLayout.vue │ │ │ └─LayoutProvider.vue │ │ ├─pages/ │ │ │ ├─AboutPage.vue │ │ │ ├─HomePage.vue │ │ │ ├─MovieDetailsPage.vue │ │ │ ├─MoviesPage.vue │ │ │ └─NotFoundPage.vue │ │ └─index.ts | ├─App.vue │ └─main.ts
Let's make a page to show when a user visits a path that isn't defined.
Write NotFoundPage.vue like this.
123<template> <h1>404 Not Found page!</h1> </template>
Then add a route object that matches every path.
To match all sub-paths with a dynamic path named pathMatch, also write a regular expression.
Vue Router doesn't check matches in the order you wrote them in the array; it scores how specific each path is.
This route object's path is the least specific, so wherever you put it in the array, it matches only after all other route objects have been checked.
Still, it's common to write it at the very end of the array so the reading order matches the actual behavior.
12345678910111213141516// ... import NotFoundPage from './pages/NotFoundPage.vue' const router = createRouter({ // ... routes: [ // ... { name: 'NotFound', path: '/:pathMatch(.*)*', component: NotFoundPage } ] }) export default router
# Navigation guards
A navigation guard is a feature that can cancel a navigation or redirect it to another path, depending on certain conditions, before the page changes.
It's mainly used to check user authentication, such as whether the user has signed in.
12345678910111213141516171819202122├─src/ │ ├─components/ │ │ └─TheHeader.vue │ ├─routes/ │ │ ├─guards/ │ │ │ ├─guestOnly.ts │ │ │ ├─requiresAuth.ts │ │ │ └─index.ts │ │ ├─layouts/ │ │ │ ├─DefaultLayout.vue │ │ │ ├─EmptyLayout.vue │ │ │ └─LayoutProvider.vue │ │ ├─pages/ │ │ │ ├─AboutPage.vue │ │ │ ├─HomePage.vue │ │ │ ├─MovieDetailsPage.vue │ │ │ ├─MoviesPage.vue │ │ │ ├─NotFoundPage.vue │ │ │ └─SignInPage.vue │ │ └─index.ts | ├─App.vue │ └─main.ts
First, write the sign-in page (SignInPage.vue).
For testing, if the user enters both an ID (email) and a password, we treat it as a successful sign-in and save an access token (accessToken) to local storage.
1234567891011121314151617181920212223242526272829303132333435<script setup lang="ts"> import { useRoute, useRouter } from 'vue-router' const route = useRoute() const router = useRouter() function handleSubmit(event: Event) { // Get the `<form>` data and convert it to an object that's easy to use. const formData = new FormData(event.target as HTMLFormElement) const { email, password } = Object.fromEntries(formData) as Record<string, string> // If all the sign-in info is there, sign in temporarily. if (email && password) { localStorage.setItem('accessToken', 'abcd1234') const redirectTo = (route.query.redirectTo as string) || '/' router.push(redirectTo) } } </script> <template> <div> <h1>Sign In page!</h1> <form @submit.prevent="handleSubmit"> <input name="email" type="email" placeholder="Email" /> <input name="password" type="password" placeholder="Password" /> <button type="submit">Sign in</button> </form> </div> </template>
Register the sign-in page as a route object.
Set the route object's meta.guestOnly property so that only users who haven't signed in can visit the sign-in page.
On the other hand, set the meta.auth property on the movie search and details pages so that only signed-in users can visit them.
1234567891011121314151617181920212223242526272829// ... const router = createRouter({ // ... routes: [ { name: 'SignIn', path: '/signin', component: SignInPage, meta: { guestOnly: true } }, { name: 'Movies', path: '/movies', component: MoviesPage, meta: { auth: true }, children: [ // ... ] } // ... ] }) export default router
Write navigation guards that act on the meta values you just set.
requiresAuth is a guard that allows access only to signed-in users, and guestOnly is a guard that allows access only to users who haven't signed in.
When access is denied, the redirect function returns the path to go to instead.
If you also pass the originally requested path (to.fullPath) as the redirectTo query string, you can send the user back to that path after sign-in.
123456789101112131415import type { RouteGuard } from '.' export const requiresAuth: RouteGuard = { guard(to) { if (to.meta.auth) { const token = localStorage.getItem('accessToken') // Check that the token is valid! if (!token) { return false } } return true }, redirect: to => ({ path: '/signin', query: { redirectTo: to.fullPath } }) }
123456789101112131415import type { RouteGuard } from '.' export const guestOnly: RouteGuard = { guard(to) { if (to.meta.guestOnly) { const token = localStorage.getItem('accessToken') // Check whether there is a valid token! if (token) { return false } } return true }, redirect: () => '/' }
Now let's apply the navigation guards we wrote.
The callback of the router.beforeEach method is called right before any page is visited, and its to parameter is the route object of the page being navigated to.
Through it, each guard can check the values of the route object's meta property.
Just as we extended the type of meta.layout earlier in Layouts, extend the meta.auth and meta.guestOnly properties used by the guards in the router interface too.
If you don't, the types of the two properties remain unknown.
1234567891011121314151617181920212223import type { RouteLocationNormalizedGeneric, RouteLocationRaw } from 'vue-router' import router from '@/routes' import { requiresAuth } from './requiresAuth' import { guestOnly } from './guestOnly' // Extend the router interface declare module 'vue-router' { interface RouteMeta { auth?: boolean guestOnly?: boolean } } router.beforeEach(to => { if (!requiresAuth.guard(to)) return requiresAuth.redirect(to) if (!guestOnly.guard(to)) return guestOnly.redirect(to) return true }) export interface RouteGuard { guard(to: RouteLocationNormalizedGeneric): boolean redirect(to: RouteLocationNormalizedGeneric): RouteLocationRaw }
Finally, import the guard file in /src/main.ts so the guards are actually registered.
If you skip this step, router.beforeEach is never called, and the guards don't work at all.
12345678import { createApp } from 'vue' import App from './App.vue' import router from './routes' import './routes/guards' createApp(App) .use(router) .mount('#app')
# Page transition animations
With Vue's <Transition> component, you can easily add a transition animation each time the page changes.
Let's add an animation where the old page fades out and the new page fades in over 0.3 seconds.
12345678910111213141516171819202122232425262728293031323334353637383940414243<script setup lang="ts"> import { RouterView, useRoute } from 'vue-router' import Default from './DefaultLayout.vue' import Empty from './EmptyLayout.vue' declare module 'vue-router' { interface RouteMeta { layout?: keyof typeof layouts } } const layouts = { Default, Empty } as const const route = useRoute() </script> <template> <Component :is="layouts[route.meta.layout || 'Default']"> <Transition name="fade" mode="out-in"> <RouterView /> </Transition> </Component> </template> <style scoped> .fade-enter-active, .fade-leave-active { transition: opacity 0.3s; } .fade-enter-from, .fade-leave-to { opacity: 0; } .fade-enter-to, .fade-leave-from { opacity: 1; } </style>
# Lazy loading pages
So far, we have imported every page component statically at the top of the file.
This puts even pages the user may never visit into the initial bundle, so the first screen takes longer to appear.
If you set the component property to a function that calls import instead of a component, that page is split into a separate file and downloaded when it's visited.
However, the page the user sees first is needed anyway, so it's better to leave it as is without lazy loading.
1234567891011121314151617181920212223// ... import HomePage from './pages/HomePage.vue' const router = createRouter({ // ... routes: [ { path: '/', component: HomePage }, { path: '/about', component: () => import('./pages/AboutPage.vue') }, { name: 'Movies', path: '/movies', component: () => import('./pages/MoviesPage.vue'), // ... } // ... ] })
Vue Router downloads the page before it confirms the navigation.
While it downloads, the previous page stays on screen and the screen never goes blank, so you don't need a separate boundary component for loading.
However, on a slow network, it can take a while from the click until the screen changes, so it's a good idea to add an indicator that shows something is in progress.
Turn it on in router.beforeEach and off in router.afterEach.
But if the download fails, router.onError is called instead of afterEach, so turn it off there too, or the indicator stays on screen.
12345678910111213141516171819202122import { ref } from 'vue' // ... const router = createRouter({ // ... }) // State that is true while a page is downloading export const isPageLoading = ref(false) router.beforeEach(() => { isPageLoading.value = true }) router.afterEach(() => { isPageLoading.value = false }) // If the download fails, afterEach isn't called. router.onError(() => { isPageLoading.value = false }) export default router
Now use this state in the layout provider to show a loading indicator.
123456789101112131415161718<script setup lang="ts"> // ... import { isPageLoading } from '@/routes' // ... </script> <template> <Component :is="layouts[route.meta.layout || 'Default']"> <div v-if="isPageLoading">Loading...</div> <Transition name="fade" mode="out-in"> <RouterView /> </Transition> </Component> </template> <!-- ... -->
# File-based routing
So far, we have managed paths by writing route objects directly in the routes array.
Starting with Vue Router 5, it also provides file-based routing, which creates routes automatically from the file paths of page components.unplugin-vue-router, which used to be a separate package, has been merged into Vue Router, so there is no extra package to install.
First, register the plugin in the bundler.
The routes must be collected before the Vue plugin processes the page components, so you must write VueRouter() before vue().
12345678910import { defineConfig } from 'vite' import VueRouter from 'vue-router/vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [ VueRouter(), vue() ] })
After you register the plugin and run the development server once, a typed-router.d.ts file is created in the project root.
The name, path, and dynamic path param types of each route are written to this file automatically, so add it to the include option so TypeScript can recognize it.
1234567891011{ "compilerOptions": { // ... }, "include": [ "src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "./typed-router.d.ts" ] }
Now, when you add page components to the /src/pages folder, the file path becomes the access path as is.
Wrapping a file name in square brackets ([]) makes it a dynamic route, and placing a file next to a folder with the same name makes a nested route.
1234567├─src/ │ └─pages/ │ ├─movies/ │ │ └─[movieId].vue │ ├─about.vue │ ├─index.vue │ └─movies.vue
| File | Generated path |
|---|---|
pages/index.vue |
/ |
pages/about.vue |
/about |
pages/movies.vue |
/movies |
pages/movies/[movieId].vue |
/movies/:movieId |
The folder structure also replaces the children property we wrote by hand earlier in Nested routes.movies/[movieId].vue becomes a child route of movies.vue and renders at the <RouterView /> you wrote in movies.vue.
Import the generated routes from vue-router/auto-routes and pass them to the createRouter function as is.
123456789import { createRouter, createWebHistory } from 'vue-router' import { routes } from 'vue-router/auto-routes' const router = createRouter({ history: createWebHistory(), routes }) export default router
Lazy loading pages is already applied to every page without writing anything extra.
On the other hand, for pages needed right away, such as the first screen, return 'sync' from the plugin's importMode option to include them in the initial bundle.
12345678export default defineConfig({ plugins: [ VueRouter({ importMode: filepath => (filepath.endsWith('src/pages/index.vue') ? 'sync' : 'async') }), vue() ] })
Info you used to write in route objects, such as meta, is set with the definePage macro in the page component.
It's a macro handled at compile time, so you don't need to import it.
1234567<script setup lang="ts"> definePage({ meta: { auth: true } }) </script>
Thanks to the generated types, if you pass a route name to the useRoute() composable, even the types of that page's dynamic path params are inferred.
In the example below, movieId is inferred as string without any type assertion.
123456<script setup lang="ts"> import { useRoute } from 'vue-router' const route = useRoute('/movies/[movieId]') const movieId = route.params.movieId </script>
Route names are also created automatically from file paths.
The name of pages/movies/[movieId].vue is '/movies/[movieId]', not 'MovieDetails', so code that navigated by the names we set by hand earlier won't work as is.
To keep the names you used, write them in the name property of definePage, and set per-route guards (beforeEnter) the same way.
123456789101112<script setup lang="ts"> // ... definePage({ name: 'MovieDetails', beforeEnter: to => { console.log(to.fullPath) } }) // ... </script>
Since we changed the name, also change the useRoute('/movies/[movieId]') we wrote above to useRoute('MovieDetails').
The /:pathMatch(.*)* route we wrote in Not found page is replaced by a pages/[...pathMatch].vue file, with three dots at the start inside the square brackets.
Apart from these two things, you can use everything we covered earlier as is.
File-based routing only replaces the routes array, so you don't need to touch layouts, scroll restoration, router.beforeEach guards, or transition animations.
Finally, if you use ESLint, you need to adjust one rule.
Every HTML element name is a single word, so vue/multi-word-component-names requires component names of two or more words to avoid clashing with current or future elements.
But a page file's name is its access path, so it has to be a single word like about.vue.
Don't turn the rule off for the whole project; turn it off only for the pages folder.
In a flat config, later objects override earlier ones, so you must write this after the eslint-plugin-vue preset that turns the rule on.
123456789export default defineConfigWithVueTs( // ... { files: ['src/pages/**/*.vue'], rules: { 'vue/multi-word-component-names': 'off' } } )
If your config is JavaScript rather than TypeScript, definePage trips the no-undef rule, so also declare it as a global.
12345{ languageOptions: { globals: { definePage: 'readonly' } } }
# Deployment
When you deploy a Vue Router project that uses HTML5 mode (createWebHistory), you need a server setting that rewrites every request to index.html.
Unlike a redirect, which sends the address somewhere else, a rewrite keeps the address the user entered and only changes the file the server responds with.
This makes sure the application works correctly even when users type a URL directly to visit a specific page or refresh the page.
Let's look at the settings needed for some major hosting services where you can deploy easily.
# Vercel
If you deploy with Vercel, create a vercel.json file in the project root and add the following configuration.
123{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
# Netlify
If you deploy with Netlify, create a _redirects file in the project's public folder (/public) and add the following configuration.
1/* /index.html 200
Or create a netlify.toml file in the project root and add the following configuration.
1234[[redirects]] from = "/*" to = "/index.html" status = 200
# Firebase
If you deploy with Firebase, create a firebase.json file in the project root and add the following configuration.
1234567891011{ "hosting": { "public": "dist", "rewrites": [ { "source": "**", "destination": "/index.html" } ] } }
Thanks for reading to the end.
Likes and kind comments help keep this blog going!