Remember the old days of the web? You'd click a link, the screen would flash white, and a whole new HTML page would load from the server. It worked, sure, but it always felt a little... clunky. Then came Single Page Applications (SPAs), and suddenly our web apps felt fluid, fast, and alive.
But this new magic brought a new puzzle: if we're only working with one index.html file, how do we create the illusion of having multiple pages? How do we handle URLs like /about or /users/123?
That, my friend, is exactly where Vue Router steps in. It's not just a library; it's the official, canonical storyteller for your Vue 3 application. It listens to the URL and tells your app which components to show. Think of it as the traffic cop, the GPS, and the tour guide all rolled into one. And honestly, once you get the hang of it, you’ll wonder how you ever built anything in Vue without it.
So, grab a coffee. Let’s unravel the mysteries of Vue Router navigation together.
First Things First: Why Do I Even Need This?
Let's be real for a second—you could technically build a multi-view app by just using v-if to swap components. I've seen it done. And let me tell you, it becomes a tangled mess of state variables and conditional logic so fast it'll make your head spin.
Vue Router is what gives us structure and sanity. It maps your components to specific URL paths, handles the browser history (so the back button actually works!), and provides powerful tools for things like authentication and data fetching before a view is even rendered.
It’s what turns your pile of components into a real, navigable application.
Getting It Installed
As with most things in the modern JS world, we'll kick things off with a simple command. We’ll be using vue-router@4, which is the version specifically designed for Vue 3.
npm install vue-router@4
Or if you're a yarn person:
yarn add vue-router@4
Easy enough, right? Now for the fun part: making it actually do something.
The Basic Setup: Your First Two Routes
Every great journey starts with a single step. For us, that means creating a Home and an About page. We just need to tell Vue Router about these pages and then tell our main Vue app to use our brand-new router.
It's a simple three-step dance:
- Define your routes: This is basically a map of paths to components.
- Create the router instance: We'll configure it with our routes and a history mode.
- Plug it into Vue: Just tell your main app instance to
use()the router.
Let's create a new file. I usually like to put mine in src/router/index.js to keep things organized.
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import AboutView from '../views/AboutView.vue'
const routes = [
{
path: '/',
name: 'Home',
component: HomeView
},
{
path: '/about',
name: 'About',
component: AboutView
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
export default router
See what’s happening here? We’re just creating a simple array of objects. Each object is a rule that says, "When the user goes to this path, show them this component." Simple as that. The createWebHistory() part is what gives us those nice, clean URLs without the old-school hash (#).
Next up, we need to update src/main.js to actually use this router.
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router' // <-- Import our router
const app = createApp(App)
app.use(router) // <-- Tell Vue to use it
app.mount('#app')
Okay, one last piece of the puzzle. How do we tell our app where to render these components and how to actually link to them? For that, Vue Router gives us two special components: <router-link> and <router-view>.
<router-link to="/about">: This is the smart way to create links. It'll render as a regular<a>tag, but it cleverly prevents a full page reload.<router-view />: Think of this as a placeholder. It’s the spot where the component for the current route will be rendered.
Let's pop open our main App.vue and put them to use.
<!-- src/App.vue -->
<template>
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<main>
<!-- The magic happens here -->
<router-view />
</main>
</template>
<style>
/* A little styling never hurt anybody */
#nav {
padding: 30px;
text-align: center;
}
#nav a {
font-weight: bold;
color: #2c3e50;
}
#nav a.router-link-exact-active {
color: #42b983; /* Vue Green! */
}
</style>
And boom! Just like that, you have a working, multi-page SPA. Click the links, watch the URL change, and see the content swap out instantly. No white flashes. No full reloads. That, right there, is the power of Vue Router.
The Real World: Dynamic Routes
Okay, static pages like "Home" and "About" are great, but let's be honest, most real-world apps have pages that depend on data. I'm talking about user profiles (/users/123), blog posts (/posts/my-awesome-post), or products (/products/45). We can't create a new route for every single user, right?
Of course not. This is where dynamic route segments come in, and they are an absolute game-changer. You just use a colon (:) to mark a part of the path as a dynamic parameter.
Let's set up a route for a user profile page to see how it works.
// src/router/index.js
// ... inside your routes array
{
path: '/users/:id', // <-- The colon marks 'id' as a dynamic param
name: 'UserDetails',
component: () => import('../views/UserDetailsView.vue') // Lazy load this one!
}
Now, any URL that matches this pattern—whether it's /users/1, /users/taylor, or /users/anything-at-all—will render our UserDetailsView component. Pretty cool.
So, how do we actually get that ID inside our component? Vue Router gives us a handy composable for this called useRoute.
<!-- src/views/UserDetailsView.vue -->
<script setup>
import { useRoute } from 'vue-router'
import { ref, onMounted, watch } from 'vue'
const route = useRoute()
const userId = ref(route.params.id)
const user = ref(null)
const fetchUserData = async (id) => {
// In a real app, you'd fetch from an API
console.log(`Fetching data for user ${id}...`)
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
user.value = await response.json()
}
// Fetch data when the component is first created
onMounted(() => {
fetchUserData(userId.value)
})
// What if the user navigates from /users/1 to /users/2?
// The component doesn't get unmounted, so onMounted won't fire again.
// We need to WATCH the route params for changes!
watch(
() => route.params.id,
(newId) => {
userId.value = newId
fetchUserData(newId)
}
)
</script>
<template>
<div>
<h1>User Profile</h1>
<div v-if="user">
<p><strong>ID:</strong> {{ user.id }}</p>
<p><strong>Name:</strong> {{ user.name }}</p>
<p><strong>Email:</strong> {{ user.email }}</p>
</div>
<div v-else>
<p>Loading user data...</p>
</div>
</div>
</template>
That watch is super important. Seriously, it’s a common mistake I see people make all the time. They fetch data in onMounted and then get confused why navigating from one user profile to another doesn't update the data. You have to remember: Vue is smart and reuses components when it can, so onMounted won't fire again!
The Doorman: Navigation Guards
Okay, this is where Vue Router goes from being a simple map to a powerful security guard for your application. Navigation guards are basically functions that get called before, during, or after a navigation event.
The most common use case? You guessed it: authentication. You probably don't want just anyone accessing the /dashboard, right?
Let's set up a global beforeEach guard. This function runs before every single route change, no exceptions. It’s like having a bouncer check everyone's ID at the main entrance of your app.
// src/router/index.js
// ... after creating the router instance
router.beforeEach((to, from, next) => {
const isAuthenticated = !!localStorage.getItem('user-token') // A simple check
// We can add metadata to our routes
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
if (requiresAuth && !isAuthenticated) {
// This route requires auth, and the user isn't logged in.
// Redirect them to the login page.
console.log('Access denied. Redirecting to login.')
next({ name: 'Login' })
} else {
// All good, proceed to the route.
next()
}
})
// Now, let's add that meta field to a protected route
const routes = [
// ... other routes
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('../views/DashboardView.vue'),
meta: { requiresAuth: true } // <-- Here's the metadata!
},
{
path: '/login',
name: 'Login',
component: () => import('../views/LoginView.vue')
}
]
This is huge. The to, from, and next arguments are your main tools here:
to: The route object for where the user is trying to go.from: The route object for where the user is coming from.next(): This function is the key. You must call it to resolve the hook, or your app will just hang.next(): Let the user proceed as planned.next(false): Cancel the navigation completely.next('/login')ornext({ name: 'Login' }): Redirect the user to a different route.
With this simple guard, you've just built a basic authentication flow. It's clean, centralized, and keeps your component logic focused on what it should be: presentation, not security. For a deeper dive, you can always check out the official docs on navigation guards.
Keeping It Snappy: Lazy Loading
Remember how I lazy-loaded a few components earlier with that () => import(...) syntax? Let's take a moment to talk about why that's so important.
As your app grows, your JavaScript bundle size inevitably grows with it. If you import every single component upfront, your users have to download a massive file just to see the login screen. That's... not a great user experience.
Lazy loading is the solution. It splits your code into smaller chunks that are only downloaded when the user actually navigates to that specific route. It's honestly one of the easiest performance wins you can get.
Here’s a quick look at the difference:
Eager Loading (The Slow Way):
import AboutView from '../views/AboutView.vue'
const routes = [{ path: '/about', component: AboutView }]
// AboutView.vue is bundled into the main app.js file.
Lazy Loading (The Fast Way):
const routes = [
{
path: '/about',
// The component is only fetched from the server when the user visits /about
component: () => import('../views/AboutView.vue')
},
{
path: '/admin',
// You can even give chunks custom names for easier debugging!
component: () => import(/* webpackChunkName: "admin-bundle" */ '../views/AdminView.vue')
}
]
My personal rule of thumb? Lazy load everything that isn't immediately visible on the initial page load. Your users and their data plans will thank you for it.
Taking the Wheel: Programmatic Navigation
Sometimes, you need to navigate based on a user action, like after they submit a form or click a button that needs to do some work first. In these cases, a simple <router-link> isn't always the right tool for the job.
For this, we have programmatic navigation. We can use another composable, useRouter, to get direct access to the router instance itself.
<!-- src/components/LoginForm.vue -->
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter() // Get the router instance
const username = ref('')
const password = ref('')
const error = ref(null)
const handleLogin = async () => {
error.value = null
try {
// Pretend we're calling an API
const success = await fakeApiLogin(username.value, password.value)
if (success) {
// Login was successful, let's go to the dashboard!
router.push({ name: 'Dashboard' })
} else {
throw new Error('Invalid credentials')
}
} catch (err) {
error.value = err.message
}
}
const fakeApiLogin = (user, pass) => {
return new Promise(resolve => {
setTimeout(() => {
resolve(user === 'admin' && pass === 'password')
}, 500)
})
}
</script>
<template>
<form @submit.prevent="handleLogin">
<div>
<label for="username">Username</label>
<input id="username" v-model="username" type="text" />
</div>
<div>
<label for="password">Password</label>
<input id="password" v-model="password" type="password" />
</div>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit">Log In</button>
</form>
</template>
The router.push() method is incredibly versatile. You can pass it a simple string path like (/dashboard), or an object for more complex navigation involving names, params, and query strings.
router.push({ name: 'UserDetails', params: { id: 123 } })->/users/123router.push({ path: '/search', query: { q: 'vue router' } })->/search?q=vue+routerrouter.back()-> Go back one page in history.router.replace('/new-location')-> Navigates without adding a new entry to the history stack (useful for login flows).
If you're building interactive applications, you'll find yourself reaching for useRouter all the time. And if you're ready to explore more advanced patterns, our guide on Vue 3 state management could be a great next step.
Final Thoughts
Wow, we've covered a lot of ground here—from the absolute basics of setting up routes to the more advanced powers of navigation guards and lazy loading.
I know Vue Router can feel a little abstract at first. It’s not a button or a form input you can see; it’s more like the invisible framework that gives your application its shape and flow. But once it clicks—and I promise it will—you'll see that it's an elegant, powerful, and truly indispensable tool.
You've learned how to map components to URLs, handle dynamic data, protect your routes, and optimize performance. You now have the core knowledge to build complex, robust, and user-friendly Single Page Applications in Vue 3.
So go on. Go build something amazing.
Frequently Asked Questions
What's the difference between
createWebHistoryandcreateWebHashHistory? Great question.createWebHistoryuses the browser's History API to create clean, normal-looking URLs (likemysite.com/users/1). This is generally what you want, but it does require your server to be configured to handle these URLs, usually by redirecting all requests back to yourindex.html. On the other hand,createWebHashHistoryuses a hash (#) in the URL (likemysite.com/#/users/1). The big advantage is that it doesn't require any special server configuration, which makes it great for static file servers or environments where you can't control the server config.
How do I handle a "404 Not Found" page? Oh, that's an easy one! You just add a catch-all route at the very end of your routes array. It uses a special parameter with a bit of regex to match everything that hasn't been matched yet.
{ // will match everything and put it under `route.params.pathMatch` path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('../views/NotFoundView.vue') }Since the router tries to match routes from top to bottom, putting this one last ensures it only catches URLs that didn't match any of your other, more specific routes.
Can I have nested layouts, like a dashboard with a sidebar? Absolutely! This is a core feature called Nested Routes, and it's super useful. You define a parent route with a component that includes its own
<router-view />. Then, you add achildrenarray to that route's configuration to define all the sub-routes. It's perfect for creating complex layouts like admin panels or user settings pages.
How can I pass props to a route component directly? Yep, you can use the
propsoption in your route configuration. By settingprops: true, the route params (like our:idfrom before) will be passed as props to the component. This can make your component a lot more reusable and decoupled from the router itself.// router/index.js { path: '/users/:id', component: UserDetails, props: true } // UserDetails.vue <script setup> defineProps({ id: String }) </script>