Vue Router basics: Vue & Nuxt best practices explained
Building single-page applications means you need a solid routing strategy that scales across your team's codebase. Vue Router basics: Vue & Nuxt best practices explained is the foundation that separates a maintainable app from one that becomes a tangled mess as it grows. In this article, we'll walk through route definition, dynamic parameters, nested layouts, and lazy loading—the core patterns we use in production to keep navigation predictable and performant.
Define routes — Paths mapped to components
At its core, routing in Vue is about mapping URL paths to Vue components. When a user navigates to /about, Vue Router knows to render the About component. This is where everything starts, and getting it right early saves debugging headaches later.
In a fresh Vue project, you define routes in a dedicated router file. Here's how we typically structure it:
// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import Home from '../pages/Home.vue'
import About from '../pages/About.vue'
import NotFound from '../pages/NotFound.vue'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/about',
name: 'about',
component: About
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound
}
]
export const router = createRouter({
history: createWebHistory(),
routes
})
Notice we give each route a name property. Named routes matter in production—they let you reference routes without hardcoding paths, which is critical when your marketing team changes URLs. Use router.push({ name: 'about' }) instead of router.push('/about') whenever possible.
The catch-all route at the bottom handles 404s. It comes last because Vue Router matches routes top-to-bottom, and you want it to catch only paths that didn't match earlier rules.
In Nuxt, routing is file-based by convention. Create files in the pages/ directory and Nuxt auto-generates the router for you:
// pages/index.vue (maps to /)
// pages/about.vue (maps to /about)
// pages/404.vue (handled automatically)
This is a productivity win, but it means you lose explicit control over the routing config. In large teams, some developers prefer explicit Vue Router setup because it's easier to audit and reason about all routes in one place.
Dynamic params — /products/:id with useRoute
Real applications need dynamic routes. A product listing might have a route like /products/:id where id is the actual product identifier. Vue Router extracts these params and passes them to your component.
Here's a typical flow:
// src/router/index.ts
{
path: '/products/:id',
name: 'product-detail',
component: ProductDetail
}
// src/pages/ProductDetail.vue
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { ref, onMounted, watch } from 'vue'
const route = useRoute()
const product = ref(null)
const loading = ref(false)
const error = ref('')
const loadProduct = async (id: string | string[]) => {
loading.value = true
error.value = ''
try {
const res = await fetch(`/api/products/${id}`)
if (!res.ok) throw new Error('Product not found')
product.value = await res.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
onMounted(() => {
loadProduct(route.params.id)
})
// Watch for route param changes
watch(
() => route.params.id,
(newId) => {
loadProduct(newId)
}
)
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="product">
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
</div>
</template>
Notice the watch on route.params.id. This is crucial: if a user is on /products/1 and clicks a link to /products/2, Vue reuses the same component instance. Without the watcher, your component won't reload the data. This is a mistake we see often in codebases.
In Nuxt, the file structure is pages/products/[id].vue, and you access params via defineProps:
// pages/products/[id].vue
<script setup lang="ts">
defineProps({
id: String
})
const product = ref(null)
// id is reactive, so watchers and effects trigger automatically
watchEffect(async () => {
product.value = await $fetch(`/api/products/${props.id}`)
})
</script>
Nested routes — Layouts with child routes
Applications need hierarchical structures. You might have a dashboard with a sidebar, and inside it, separate pages for settings, profile, and notifications. Instead of duplicating the sidebar layout in each component, use nested routes.
// src/router/index.ts
{
path: '/dashboard',
name: 'dashboard-layout',
component: DashboardLayout,
children: [
{
path: '',
name: 'dashboard',
component: DashboardHome
},
{
path: 'settings',
name: 'settings',
component: Settings
},
{
path: 'profile',
name: 'profile',
component: Profile
}
]
}
The DashboardLayout component wraps the child routes with a sidebar:
// src/layouts/DashboardLayout.vue
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<template>
<div class="dashboard">
<aside class="sidebar">
<nav>
<a @click="router.push({ name: 'dashboard' })">Home</a>
<a @click="router.push({ name: 'settings' })">Settings</a>
<a @click="router.push({ name: 'profile' })">Profile</a>
</nav>
</aside>
<main class="content">
<router-view />
</main>
</div>
</template>
The <router-view /> component is where Vue Router renders the matched child component. When you navigate to /dashboard/settings, the Settings component renders inside that slot, and the sidebar persists.
Nuxt handles this with directory structure:
// pages/dashboard.vue (the layout)
// pages/dashboard/index.vue (maps to /dashboard)
// pages/dashboard/settings.vue (maps to /dashboard/settings)
// pages/dashboard/profile.vue (maps to /dashboard/profile)
Use nested routes to reduce prop-drilling and component duplication. In one e-commerce platform we worked on, moving from flat routes to nested layouts cut component size by 30% and made team onboarding faster.
Lazy loading — component: () => import('./Page.vue') splits bundles
As applications grow, the initial JavaScript bundle can balloon. If all routes are bundled upfront, users wait longer for first paint. Lazy loading routes means each route's component loads on-demand, splitting your bundle intelligently.
// src/router/index.ts
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('../pages/Home.vue')
},
{
path: '/about',
name: 'about',
component: () => import('../pages/About.vue')
},
{
path: '/admin',
name: 'admin',
component: () => import('../pages/Admin.vue')
}
]
With lazy loading, your initial bundle includes only the router and the Home component. When the user navigates to /about, the bundler fetches the About chunk. This is a free performance win for most apps.
Webpack and Vite both understand this pattern and generate separate chunks automatically. You can verify it works by opening DevTools Network tab and watching chunk files load as you navigate.
For better control, use webpack's magic comment to name chunks explicitly:
component: () => import(
/* webpackChunkName: "admin" */
'../pages/Admin.vue'
)
This makes bundle analysis and debugging easier. In production, you'll see files like admin.abc123.js instead of meaningless hashes.
Nuxt does this automatically for all pages. Every pages/*.vue file becomes a lazy-loaded route by default. No extra config needed.
Common mistakes
- Forgetting to watch route params. If your component fetches data based on
route.params.id, you must watch it. Otherwise, navigating between similar routes won't trigger data reloads. - Using router.push() with hardcoded paths. When URLs change, you'll hunt through the codebase for string references. Use named routes and let the router config be the single source of truth.
- Lazy loading everything. Lazy loading adds HTTP overhead. Lazy load code-split routes that users navigate to after initial load, not your homepage. Your metrics will tell you what to prioritize.
- Ignoring nested route structure. Flat routes work until they don't. By day 50 of a project, you'll regret not thinking about hierarchies early. Refactoring from flat to nested is tedious.
- Not handling 404s. A missing catch-all route leaves users confused when they land on a non-existent path. Always include a fallback 404 page at the end of your routes array.
Key takeaways
- Define routes in a central config or file structure and use named routes to decouple navigation from hardcoded paths.
- Access dynamic params with
useRoute()in Vue orpropsin Nuxt, and watch them to refresh data when users navigate between similar routes. - Use nested routes and
<router-view />to share layouts and reduce duplication across hierarchical pages. - Lazy load route components with arrow functions to split your bundle and improve time-to-interactive for initial page load.
- Plan your routing structure early and include a catch-all 404 route at the end of your routes array.






Comments
Loading comments…