Vue Router

Route setup, dynamic segments, nested routes, and a complete navigation-guard auth example.

Why Vue needs a separate routing library

Vue itself renders components given some data — it has no built-in concept of a URL. Vue Router is the official routing library for Vue, maintained by the same core team, and it's what turns a Vue app into a single-page application with multiple navigable "pages" that update the URL without a full page reload.

Bash
npm install vue-router@4

Basic setup

Routes are declared as an array mapping a URL path to a component, then installed as a plugin on the app:

Javascript
// router/index.js
import { createRouter, createWebHistory } from "vue-router";
import HomePage from "../views/HomePage.vue";
import AboutPage from "../views/AboutPage.vue";
import ProductPage from "../views/ProductPage.vue";

const routes = [
  { path: "/", component: HomePage },
  { path: "/about", component: AboutPage },
  { path: "/products/:id", component: ProductPage }, // :id is a dynamic segment
];

export const router = createRouter({
  history: createWebHistory(),
  routes,
});
Javascript
// main.js
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router";

createApp(App).use(router).mount("#app");
HTML
<!-- App.vue -->
<script setup>
</script>

<template>
  <nav>
    <RouterLink to="/">Home</RouterLink>
    <RouterLink to="/about">About</RouterLink>
  </nav>
  <RouterView />
</template>

createWebHistory() uses the browser's real History API, producing clean URLs (/about, not /#/about) — this is the standard choice for a normal web app; createWebHashHistory() exists as an alternative for static hosting environments with no server-side routing support at all. <RouterView /> is where the component matching the current URL actually renders, and <RouterLink> replaces a plain <a> tag for internal navigation — like next/link or React Router's <Link>, it updates the URL and swaps the rendered view without a full page reload.

Dynamic segments

A path segment prefixed with : captures part of the URL, read inside the matched component via the useRoute() composable:

HTML
<!-- views/ProductPage.vue -->
<script setup>
import { useRoute } from "vue-router";

const route = useRoute();
const productId = route.params.id; // e.g. "42" for /products/42
</script>

<template>
  <h1>Product #{{ productId }}</h1>
</template>

route.params.id is always a string, exactly like a captured route parameter in Next.js or React Router — convert it explicitly (Number(productId)) before treating it as a numeric ID.

Nested routes

A route can declare children, and its own component renders a <RouterView /> marking where the matched child should appear — the same nesting pattern used by Next.js layouts and React Router's <Outlet />:

Javascript
const routes = [
  {
    path: "/dashboard",
    component: DashboardLayout,
    children: [
      { path: "overview", component: DashboardOverview }, // matches /dashboard/overview
      { path: "settings", component: DashboardSettings },   // matches /dashboard/settings
    ],
  },
];
HTML
<!-- DashboardLayout.vue -->
<script setup>
</script>

<template>
  <div class="dashboard">
    <nav>
      <RouterLink to="/dashboard/overview">Overview</RouterLink>
      <RouterLink to="/dashboard/settings">Settings</RouterLink>
    </nav>
    <RouterView /> <!-- the matched child route renders here -->
  </div>
</template>

Navigating between /dashboard/overview and /dashboard/settings keeps DashboardLayout mounted and only swaps out the inner <RouterView /> content — its own nav and any local state it holds survive the navigation, rather than being torn down and rebuilt on every route change.

A navigation guard is a function that runs before a route change completes, and can allow it, redirect elsewhere, or cancel it outright — the standard mechanism for protecting routes that require authentication:

Javascript
// router/index.js
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "../stores/auth"; // a Pinia store, covered on the next page
import HomePage from "../views/HomePage.vue";
import LoginPage from "../views/LoginPage.vue";
import DashboardPage from "../views/DashboardPage.vue";

const routes = [
  { path: "/", component: HomePage },
  { path: "/login", component: LoginPage },
  {
    path: "/dashboard",
    component: DashboardPage,
    meta: { requiresAuth: true }, // arbitrary metadata attached to the route
  },
];

export const router = createRouter({
  history: createWebHistory(),
  routes,
});

router.beforeEach((to, from) => {
  const authStore = useAuthStore();

  if (to.meta.requiresAuth && !authStore.isLoggedIn) {
    return {
      path: "/login",
      query: { redirect: to.fullPath }, // remember where they were headed
    };
  }
});

router.beforeEach registers a global guard that runs before every navigation. to.meta.requiresAuth reads metadata declared on the matched route — an ordinary, arbitrary object attached to a route definition, commonly used exactly this way to mark which routes need protection. Returning a location object from the guard redirects there instead of completing the original navigation; returning nothing (or true) lets it proceed as normal, and returning false cancels the navigation outright without redirecting anywhere.

HTML
<!-- views/LoginPage.vue -->
<script setup>
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";

const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();

async function handleLogin(credentials) {
  await authStore.login(credentials);
  router.push(route.query.redirect || "/dashboard"); // send them back where they were headed
}
</script>

Reading route.query.redirect back out on the login page completes the pattern — a visitor who was redirected away from /dashboard lands back on /dashboard (not just the homepage) once they actually log in.

Common mistakes

  • Using a plain <a href="/about"> instead of <RouterLink> — it still navigates, but forces a full page reload and loses Vue Router's client-side routing entirely.
  • Forgetting <RouterView /> in a parent layout component — its child routes still match according to the router config, but nothing renders, since there's no placeholder telling Vue Router where to put them.
  • Putting an authentication check inside individual page components instead of a global beforeEach guard — easy to forget on a newly added protected route; a single centralized guard checking to.meta.requiresAuth can't be skipped by accident the way a per-page check can.
  • Treating route.params.id as already a number — like any URL segment, it always arrives as a string and needs explicit conversion before numeric comparison or arithmetic.