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.
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:
// 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,
});
// main.js
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router";
createApp(App).use(router).mount("#app");
<!-- 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:
<!-- 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 />:
const routes = [
{
path: "/dashboard",
component: DashboardLayout,
children: [
{ path: "overview", component: DashboardOverview }, // matches /dashboard/overview
{ path: "settings", component: DashboardSettings }, // matches /dashboard/settings
],
},
];
<!-- 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.
Navigation guards: a complete auth-guard example
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:
// 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.
<!-- 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
beforeEachguard — easy to forget on a newly added protected route; a single centralized guard checkingto.meta.requiresAuthcan't be skipped by accident the way a per-page check can. - Treating
route.params.idas already a number — like any URL segment, it always arrives as a string and needs explicit conversion before numeric comparison or arithmetic.