Vue Router
A single-page application (SPA) loads one HTML page and then swaps views with JavaScript as the user moves around, without full page reloads. Users still expect real URLs: /products/42 should open product 42, the Back button should work and links should be shareable. Vue Router, the official router for Vue, connects URLs to components.
In this lesson you will set up Vue Router, link between pages, read URL parameters, navigate from code, nest routes, protect pages with navigation guards, load pages lazily and meet the new file-based routing in Vue Router 5.
Setting up the router
In a Vite project, create-vue can add Vue Router for you, or you install it yourself with npm install vue-router. You describe the routes (which path shows which component), create a router and install it on the app:
// src/router/index.js
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "../views/HomeView.vue";
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", name: "home", component: HomeView },
{ path: "/about", name: "about", component: () => import("../views/AboutView.vue") }
]
});
export default router;// src/main.js
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
createApp(App).use(router).mount("#app");Two built-in components then do the work in your templates:
<RouterLink to="/about">renders a link that changes the URL without reloading the page.<RouterView />is the placeholder where the component for the current URL appears.
Here is a complete small app you can run. It loads Vue Router from the CDN as a global VueRouter object:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Vue Router</title>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-router@5.4.0/dist/vue-router.global.prod.js"></script>
<style>
nav a { margin-right: 12px; }
.router-link-exact-active { font-weight: bold; color: #2f855a; }
</style>
</head>
<body>
<div id="app"></div>
<script>
const { createApp, computed } = Vue;
const { createRouter, createMemoryHistory, useRoute, useRouter } = VueRouter;
const products = [
{ id: 1, name: "Cotton saree", price: 1499 },
{ id: 2, name: "Leather chappals", price: 899 },
{ id: 3, name: "Brass diya set", price: 650 }
];
const HomePage = { template: `<h2>Welcome to Priya's Handloom Store, Jaipur</h2>` };
const ProductList = {
setup: () => ({ products }),
template: `
<h2>Products</h2>
<ul>
<li v-for="p in products" :key="p.id">
<RouterLink :to="{ name: 'product', params: { id: p.id } }">{{ p.name }}</RouterLink>
</li>
</ul>`
};
const ProductDetail = {
setup() {
const route = useRoute();
const router = useRouter();
const product = computed(() => products.find((p) => p.id === Number(route.params.id)));
return { product, back: () => router.back(), goHome: () => router.push("/") };
},
template: `
<div v-if="product">
<h2>{{ product.name }}</h2>
<p>₹{{ product.price }}</p>
</div>
<p v-else>No product with id {{ $route.params.id }}.</p>
<button @click="back">Back</button> <button @click="goHome">Home</button>`
};
const NotFound = { template: `<h2>404: page not found</h2>` };
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: HomePage },
{ path: "/products", component: ProductList },
{ path: "/products/:id", name: "product", component: ProductDetail },
{ path: "/:pathMatch(.*)*", component: NotFound }
]
});
const App = {
setup() {
const route = useRoute();
return { route };
},
template: `
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/products">Products</RouterLink>
<RouterLink to="/products/99">Missing product</RouterLink>
<RouterLink to="/offers">Broken link</RouterLink>
</nav>
<p><small>Current path: {{ route.fullPath }}</small></p>
<RouterView />`
};
createApp(App).use(router).mount("#app");
</script>
</body>
</html>The example uses createMemoryHistory() because it runs inside the Try-it frame, so the path is kept in memory and shown under the menu. In a real app use createWebHistory() for clean URLs.
Route params, useRoute and useRouter
A segment starting with a colon, like :id in /products/:id, is a dynamic segment. It matches any value, and the value is available as route.params.id. Params are always strings, which is why the example compares with Number(route.params.id).
useRoute()returns the current route:path,fullPath,params,query(for?sort=price, readroute.query.sort),nameandmeta. In a template you can also use$route.useRouter()returns the router, for navigating from code:router.push("/products"),router.push({ name: "product", params: { id: 2 } }),router.replace(...)(no new history entry) androuter.back().
Linking by name, as ProductList does, is safer than building path strings by hand: if you later change the path to /shop/:id, every named link still works.
When the user moves from /products/1 to /products/2, Vue Router reuses the same ProductDetail component instead of creating a new one. Code that reads the params once in setup() would not update. Use computed properties (as above) or a watcher such as watch(() => route.params.id, loadProduct, { immediate: true }).
The route /:pathMatch(.*)* matches anything not matched earlier, which makes it a 404 page. Click "Broken link" to see it.
Nested routes
Many layouts have pages inside pages: a dashboard with tabs, each tab with its own URL. Add children to a route and place a second <RouterView /> inside the parent component:
const routes = [
{
path: "/account",
component: AccountLayout, // has its own <RouterView /> inside
children: [
{ path: "", component: AccountSummary }, // /account
{ path: "orders", component: OrderHistory }, // /account/orders
{ path: "address", component: SavedAddresses } // /account/address
]
}
];Child paths without a leading slash are relative to the parent.
Navigation guards
A navigation guard runs before each navigation and can allow it, cancel it or redirect it. A global beforeEach guard is the usual way to protect pages that need a login. Mark those routes with meta, then check it:
const routes = [
{ path: "/login", name: "login", component: LoginPage },
{ path: "/orders", component: OrdersPage, meta: { requiresAuth: true } }
];
router.beforeEach((to, from) => {
const loggedIn = Boolean(localStorage.getItem("token"));
if (to.meta.requiresAuth && !loggedIn) {
return { name: "login", query: { redirect: to.fullPath } }; // redirect
}
// returning nothing (or true) allows the navigation; returning false cancels it
});A front-end guard only improves the user experience. Anyone can skip it by editing the code in their browser, so your server must check permissions on every API request too.
Lazy loading and server setup
component: () => import("../views/AboutView.vue") is a lazy-loaded route. Vite puts that page in a separate JavaScript file that is downloaded only when the user first visits it, which makes the first load faster. Use lazy loading for most routes except the home page.
With createWebHistory(), URLs look normal (/products/2). If a user refreshes that page, the browser asks the server for /products/2, which does not exist as a file, and gets a 404. The server must send index.html for unknown paths. On Apache (for example XAMPP or most shared hosting), an .htaccess file in the app's folder does it:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]If you cannot configure the server, createWebHashHistory() uses URLs like /#/products/2, which always work but look less clean and are less friendly to search engines.
Common mistakes
- Using
<a href>for internal links. A normal link reloads the whole app. Use<RouterLink>. - Treating params as numbers.
route.params.idis a string; convert it. - Reading params once in
setup(). The component is reused between/products/1and/products/2; use computed properties or a watcher. - Forgetting the server fallback. With
createWebHistory(), refreshing a deep URL needs the server to returnindex.html. - Relying only on guards for security. Always check permissions on the server.
Key points
- Vue Router maps URLs to components: define
routes, create the router with a history mode, andapp.use(router). <RouterLink>navigates without reloading;<RouterView>shows the matched component.- Dynamic segments like
:idappear inuseRoute().paramsas strings;useRouter()navigates in code. childrencreates nested routes;beforeEachguards can redirect or cancel navigation.- Lazy load routes with
() => import(...); configure the server to returnindex.htmlfor history mode. - Vue Router 5 keeps the same API and adds optional file-based routing.
Exercise
Extend the store example with a /cart route and a route named category at /category/:name that lists only products in that category (add a category field such as "clothing" or "home" to each product). Add navigation links for two categories and a guard that redirects /cart to / with a message in the query string when the cart is empty.
Show solution
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Store with Cart</title>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-router@5.4.0/dist/vue-router.global.prod.js"></script>
</head>
<body>
<div id="app"></div>
<script>
const { createApp, ref, computed } = Vue;
const { createRouter, createMemoryHistory, useRoute } = VueRouter;
const products = [
{ id: 1, name: "Cotton saree", price: 1499, category: "clothing" },
{ id: 2, name: "Leather chappals", price: 899, category: "clothing" },
{ id: 3, name: "Brass diya set", price: 650, category: "home" }
];
const cart = ref([]);
const HomePage = {
setup: () => ({ route: useRoute() }),
template: `<p v-if="route.query.msg">{{ route.query.msg }}</p><h2>Home</h2>`
};
const CategoryPage = {
setup() {
const route = useRoute();
const items = computed(() => products.filter((p) => p.category === route.params.name));
return { route, items, cart };
},
template: `
<h2>{{ route.params.name }}</h2>
<p v-for="p in items" :key="p.id">{{ p.name }} <button @click="cart.push(p)">Add</button></p>`
};
const CartPage = { setup: () => ({ cart }), template: `<h2>Cart: {{ cart.length }} item(s)</h2>` };
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: HomePage },
{ path: "/category/:name", name: "category", component: CategoryPage },
{ path: "/cart", component: CartPage, meta: { needsItems: true } }
]
});
router.beforeEach((to) => {
if (to.meta.needsItems && cart.value.length === 0) {
return { path: "/", query: { msg: "Your cart is empty" } };
}
});
createApp({
template: `
<nav>
<RouterLink to="/">Home</RouterLink> |
<RouterLink :to="{ name: 'category', params: { name: 'clothing' } }">Clothing</RouterLink> |
<RouterLink :to="{ name: 'category', params: { name: 'home' } }">Home goods</RouterLink> |
<RouterLink to="/cart">Cart</RouterLink>
</nav>
<RouterView />`
}).use(router).mount("#app");
</script>
</body>
</html>The guard checks the route's meta and redirects to the home page with a msg query parameter, which the home page displays. Once something is in the cart, the guard lets the navigation through.
Quick Quiz
1. Which component shows the page that matches the current URL?
<RouterView> is the placeholder where the matched route's component renders.
2. For the route /orders/:orderId, how do you read the id inside a component?
Dynamic segments are available in the current route's params, as strings.
3. A user refreshes /products/2 on a site using createWebHistory() and gets a server 404. What is missing?
In history mode the server must fall back to index.html so the router can handle the path.
4. What does a beforeEach guard do when it returns { name: "login" }?
Returning a route location from a guard redirects; returning false cancels.
Learn Vue.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
