SupportWriter space ↗
← Back to the journal
Vue.js

Vue Router 4: building navigation for a Single Page Application

Organize the pages of your Vue app with Vue Router: route definitions, dynamic params, nested routes, lazy loading, navigation guards, and a 404 page.

DAdministrator · 01 Oct 2026 · 5 min read

What is an SPA and why do you need a router?

In a Single Page Application, the browser loads only one HTML document. When the user moves between pages, JavaScript swaps the displayed components without reloading the page. To keep URLs meaningful, make the back button work, and allow pages to be shared via links, you need a router. Vue Router is the official router for Vue.

Installation

If the project was created with npm create vue@latest and you selected Vue Router, everything is already set up. For an existing project:

npm install vue-router@4

Defining routes

Create src/router/index.js:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const routes = [
  { path: '/', name: 'home', component: HomeView },
  { path: '/about', name: 'about', component: () => import('../views/AboutView.vue') },
  { path: '/articles/:slug', name: 'articles.show', component: () => import('../views/ArticleView.vue'), props: true },
  { path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('../views/NotFoundView.vue') },
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
  scrollBehavior(to, from, savedPosition) {
    return savedPosition ?? { top: 0 }
  },
})

export default router

Then install the router in main.js:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

A few key points:

  • component: () => import(...) is lazy loading. The page's code is split into a separate file and only downloaded when needed, which keeps the initial bundle lighter.
  • scrollBehavior restores the scroll position when the back button is pressed and scrolls to the top when a new page opens.
  • The /:pathMatch(.*)* route catches every URL that doesn't match, making it a good fit for a 404 page. Put it at the end of the list for readability.

RouterView and RouterLink

In App.vue, RouterView is where page components are rendered, while RouterLink replaces regular <a> tags:

<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>

<template>
  <nav>
    <RouterLink to="/">Home</RouterLink>
    <RouterLink :to="{ name: 'about' }">About</RouterLink>
  </nav>

  <main>
    <RouterView />
  </main>
</template>

<style>
.router-link-active {
  font-weight: 700;
}
</style>

RouterLink prevents full page reloads and automatically adds the router-link-active class to the active link. Use named routes ({ name: 'about' }) so your links don't break if the path changes later.

Dynamic params

A segment starting with a colon, such as :slug, is a param. Because the route above uses props: true, the param is passed to the component as a prop:

<!-- ArticleView.vue -->
<script setup>
import { ref, watch } from 'vue'

const props = defineProps({ slug: { type: String, required: true } })
const article = ref(null)
const status = ref('loading')

watch(() => props.slug, async (slug) => {
  status.value = 'loading'
  try {
    const response = await fetch(`/api/articles/${slug}`)
    if (!response.ok) {
      throw new Error('Article not found')
    }
    article.value = await response.json()
    status.value = 'ready'
  } catch {
    status.value = 'error'
  }
}, { immediate: true })
</script>

<template>
  <p v-if="status === 'loading'">Loading...</p>
  <p v-else-if="status === 'error'">The article could not be loaded.</p>
  <article v-else>
    <h1>{{ article.title }}</h1>
  </article>
</template>

Why use watch? When the user navigates from /articles/a to /articles/b, Vue Router reuses the same component instance. Without watch, the article data would never update.

Linking to a route with params:

<RouterLink :to="{ name: 'articles.show', params: { slug: article.slug } }">
  {{ article.title }}
</RouterLink>

Programmatic navigation

To navigate after an action, for example after saving a form, use useRouter. To read information about the current URL, use useRoute:

import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

console.log(route.params.slug, route.query.page)

async function save() {
  await sendToServer()
  router.push({ name: 'articles.show', params: { slug: 'new-title' } })
}

router.push adds a history entry, router.replace replaces the current entry, and router.back() goes back one step.

Query strings are a good fit for filters and pagination because they are preserved when a page is shared:

router.push({ name: 'home', query: { category: 'vue', page: 2 } })
// produces /?category=vue&page=2

Nested routes

Pages such as dashboards usually have a fixed layout with changing content. Use children:

{
  path: '/dashboard',
  component: () => import('../layouts/DashboardLayout.vue'),
  meta: { requiresAuth: true },
  children: [
    { path: '', name: 'dashboard', component: () => import('../views/dashboard/OverviewView.vue') },
    { path: 'articles', name: 'dashboard.articles', component: () => import('../views/dashboard/ArticlesView.vue') },
    { path: 'profile', name: 'dashboard.profile', component: () => import('../views/dashboard/ProfileView.vue') },
  ],
}

DashboardLayout.vue contains the sidebar and its own RouterView to render the child pages.

Navigation guards

Guards check something before navigation happens, such as whether the user is logged in. The meta property of the route above is read by a global guard:

router.beforeEach((to) => {
  const isLoggedIn = Boolean(localStorage.getItem('token'))

  if (to.meta.requiresAuth && !isLoggedIn) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }
})

In Vue Router 4, guards simply return a value: undefined or true to continue, false to cancel, or a location object to redirect. Meta on a parent route also applies to all of its child routes.

Remember that frontend guards are only about user experience. Data security must still be enforced on the server, because anyone can modify JavaScript running in the browser.

History mode and the server

createWebHistory produces clean URLs such as /articles/learning-vue. As a consequence, the server must return index.html for every path that doesn't match a static file. An Nginx example:

location / {
  try_files $uri $uri/ /index.html;
}

If you can't configure the server, use createWebHashHistory, which produces URLs like /#/articles/learning-vue. These URLs require no server configuration but are less SEO-friendly.

Exercise

Build an article list page that reads route.query.page for pagination, with "Previous" and "Next" buttons that use RouterLink with a query. Also add a 404 page with a link back to the home page.

← Explore more notes