Tema
Vue Router
Vue Router es el enrutador oficial de Vue. Te permite tener varias "páginas" con URLs propias (/productos, /productos/15, /carrito) dentro de una SPA, sin recargar el navegador. Aprenderás a definir rutas, usar parámetros y query strings, rutas anidadas, navegación desde código, carga diferida y rutas protegidas con guards.
Concepto
En una SPA hay un solo index.html. Vue Router observa la URL y decide qué componente mostrar dentro de <RouterView />:
txt
URL: /productos/15
│
Vue Router compara con la lista de rutas
│
App.vue
├── <header> con <RouterLink>s (siempre visible)
└── <RouterView /> → DetalleProducto.vue (cambia según la URL)
useRoute().params.id === '15'Si creaste el proyecto con create-vue y elegiste Router, ya está instalado. Si no:
bash
npm i vue-routerConfiguración básica
js
import { createRouter, createWebHistory } from 'vue-router'
import InicioView from '@/views/InicioView.vue'
const router = createRouter({
// createWebHistory: URLs limpias (/productos). Requiere configurar el servidor en producción.
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', name: 'inicio', component: InicioView },
{
path: '/productos',
name: 'productos',
// Carga diferida: este archivo se descarga sólo al visitar la ruta
component: () => import('@/views/ProductosView.vue')
},
{
path: '/productos/:id',
name: 'producto',
component: () => import('@/views/DetalleProductoView.vue'),
props: true // pasa :id como prop al componente
},
{ path: '/contacto', name: 'contacto', component: () => import('@/views/ContactoView.vue') },
// Cualquier otra ruta → 404
{ path: '/:pathMatch(.*)*', name: 'no-encontrado', component: () => import('@/views/NoEncontradoView.vue') }
],
// Al navegar, sube al inicio (o restaura la posición al usar "atrás")
scrollBehavior(to, from, posicionGuardada) {
return posicionGuardada ?? { top: 0 }
}
})
export default routerjs
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')vue
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<header class="header">
<strong>MiTienda</strong>
<nav>
<!-- RouterLink navega sin recargar y agrega la clase router-link-active -->
<RouterLink to="/">Inicio</RouterLink>
<RouterLink :to="{ name: 'productos' }">Productos</RouterLink>
<RouterLink to="/contacto">Contacto</RouterLink>
</nav>
</header>
<main class="contenido">
<RouterView />
</main>
</template>
<style>
.header { display: flex; justify-content: space-between; padding: 1rem; border-bottom: 1px solid #eee; }
.header nav { display: flex; gap: 1rem; }
.header a { color: #374151; text-decoration: none; }
/* Enlace de la ruta exacta actual */
.header a.router-link-exact-active { color: #42b883; font-weight: 700; }
.contenido { max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem; }
</style>vue
<template>
<section style="text-align: center; padding: 4rem 1rem">
<h1>404 · Página no encontrada</h1>
<RouterLink to="/">Volver al inicio</RouterLink>
</section>
</template>Rutas con nombre
Navegar con :to="{ name: 'producto', params: { id: 15 } }" en lugar de escribir la URL a mano hace que, si un día cambias la ruta, no tengas que buscar todos los enlaces.
Error común
Usar etiquetas a con href para navegar dentro de la app: recargan toda la página y pierdes el estado. Usa RouterLink (y a sólo para enlaces externos).
Parámetros de ruta
vue
<!-- src/views/DetalleProductoView.vue -->
<script setup>
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
// Con props: true en la ruta, el parámetro llega como prop (siempre string)
const props = defineProps({ id: { type: String, required: true } })
const router = useRouter()
const producto = ref(null)
const estado = ref('cargando')
// watch con immediate: carga al entrar y cuando cambia el id (/productos/1 → /productos/2)
watch(
() => props.id,
async (id) => {
estado.value = 'cargando'
try {
const res = await fetch(`https://dummyjson.com/products/${id}`)
if (!res.ok) throw new Error()
producto.value = await res.json()
estado.value = 'ok'
} catch {
estado.value = 'error'
}
},
{ immediate: true }
)
</script>
<template>
<p v-if="estado === 'cargando'">Cargando…</p>
<p v-else-if="estado === 'error'">
Producto no encontrado. <RouterLink to="/productos">Ver productos</RouterLink>
</p>
<article v-else>
<button @click="router.back()">← Regresar</button>
<h1>{{ producto.title }}</h1>
<img :src="producto.thumbnail" :alt="producto.title" width="240">
<p>{{ producto.description }}</p>
<strong>${{ producto.price }}</strong>
<p>
<RouterLink :to="{ name: 'producto', params: { id: Number(id) + 1 } }">Siguiente producto →</RouterLink>
</p>
</article>
</template>Sin props: true, lees los parámetros con useRoute():
js
import { useRoute } from 'vue-router'
const route = useRoute()
console.log(route.params.id) // "15"
console.log(route.query.orden) // de /productos?orden=precio
console.log(route.path, route.name, route.fullPath)Mismo componente, otro parámetro
Al ir de /productos/1 a /productos/2, Vue Router reutiliza el componente (no lo vuelve a montar). Por eso usamos watch sobre el id y no sólo onMounted.
Query string: filtros en la URL
Guardar filtros en la URL (/productos?categoria=laptops&pagina=2) permite compartir el enlace y que "atrás" funcione:
vue
<!-- src/views/ProductosView.vue -->
<script setup>
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const categoria = computed(() => route.query.categoria ?? '')
const pagina = computed(() => Number(route.query.pagina ?? 1))
const productos = ref([])
const total = ref(0)
const LIMITE = 8
function actualizarQuery(cambios) {
// replace: no llena el historial con cada cambio de filtro
router.replace({ query: { ...route.query, ...cambios } })
}
watch(
[categoria, pagina],
async ([cat, pag]) => {
const skip = (pag - 1) * LIMITE
const url = cat
? `https://dummyjson.com/products/category/${cat}?limit=${LIMITE}&skip=${skip}`
: `https://dummyjson.com/products?limit=${LIMITE}&skip=${skip}`
const datos = await (await fetch(url)).json()
productos.value = datos.products
total.value = datos.total
},
{ immediate: true }
)
const paginas = computed(() => Math.max(1, Math.ceil(total.value / LIMITE)))
</script>
<template>
<h1>Productos</h1>
<select :value="categoria" @change="actualizarQuery({ categoria: $event.target.value || undefined, pagina: undefined })">
<option value="">Todas</option>
<option value="smartphones">Celulares</option>
<option value="laptops">Laptops</option>
<option value="fragrances">Perfumes</option>
</select>
<ul>
<li v-for="p in productos" :key="p.id">
<RouterLink :to="{ name: 'producto', params: { id: p.id } }">{{ p.title }}</RouterLink> — ${{ p.price }}
</li>
</ul>
<nav>
<button :disabled="pagina <= 1" @click="actualizarQuery({ pagina: pagina - 1 })">← Anterior</button>
<span> Página {{ pagina }} de {{ paginas }} </span>
<button :disabled="pagina >= paginas" @click="actualizarQuery({ pagina: pagina + 1 })">Siguiente →</button>
</nav>
</template>Navegar desde código
js
import { useRouter } from 'vue-router'
const router = useRouter()
router.push('/carrito') // por ruta
router.push({ name: 'producto', params: { id: 15 } }) // por nombre
router.push({ path: '/buscar', query: { q: 'café' } }) // con query
router.replace('/login') // sin dejar entrada en el historial
router.back() // atrás
router.go(-2) // dos pasos atrás
// push devuelve una promesa: útil para hacer algo después de navegar
await router.push('/gracias')Rutas anidadas
Para secciones con su propio layout (por ejemplo, un panel con barra lateral):
js
{
path: '/cuenta',
component: () => import('@/layouts/CuentaLayout.vue'),
children: [
{ path: '', name: 'cuenta', component: () => import('@/views/cuenta/ResumenView.vue') }, // /cuenta
{ path: 'pedidos', name: 'pedidos', component: () => import('@/views/cuenta/PedidosView.vue') }, // /cuenta/pedidos
{ path: 'perfil', name: 'perfil', component: () => import('@/views/cuenta/PerfilView.vue') } // /cuenta/perfil
]
}vue
<template>
<div class="cuenta">
<aside>
<RouterLink :to="{ name: 'cuenta' }">Resumen</RouterLink>
<RouterLink :to="{ name: 'pedidos' }">Mis pedidos</RouterLink>
<RouterLink :to="{ name: 'perfil' }">Mi perfil</RouterLink>
</aside>
<section>
<!-- Aquí se muestran las rutas hijas -->
<RouterView />
</section>
</div>
</template>
<style scoped>
.cuenta { display: grid; grid-template-columns: 200px 1fr; gap: 2rem; }
aside { display: flex; flex-direction: column; gap: 0.5rem; }
aside a.router-link-exact-active { font-weight: 700; color: #42b883; }
@media (max-width: 640px) { .cuenta { grid-template-columns: 1fr; } }
</style>Rutas protegidas: guards y meta
Marca las rutas que requieren sesión con meta y verifica en un guard global:
js
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'inicio', component: () => import('@/views/InicioView.vue') },
{ path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { soloInvitados: true } },
{
path: '/cuenta',
component: () => import('@/layouts/CuentaLayout.vue'),
meta: { requiereAuth: true },
children: [{ path: '', name: 'cuenta', component: () => import('@/views/cuenta/ResumenView.vue') }]
},
{
path: '/admin',
name: 'admin',
component: () => import('@/views/AdminView.vue'),
meta: { requiereAuth: true, rol: 'admin' }
}
]
})
// Se ejecuta antes de cada navegación
router.beforeEach((to) => {
const auth = useAuthStore() // store de Pinia (lección 7)
// to.meta combina el meta de la ruta y de sus padres
if (to.meta.requiereAuth && !auth.estaAutenticado) {
// Redirige al login recordando a dónde quería ir
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.meta.rol && auth.usuario?.rol !== to.meta.rol) {
return { name: 'inicio' }
}
if (to.meta.soloInvitados && auth.estaAutenticado) {
return { name: 'cuenta' }
}
// no devolver nada (o true) = permitir la navegación
})
// Título de la pestaña por ruta
router.afterEach((to) => {
document.title = to.meta.titulo ? `${to.meta.titulo} | MiTienda` : 'MiTienda'
})
export default routervue
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const email = ref('')
const password = ref('')
const error = ref('')
async function entrar() {
error.value = ''
try {
await auth.login(email.value, password.value)
// Regresa a donde quería ir, o a su cuenta
router.replace(route.query.redirect ?? { name: 'cuenta' })
} catch (e) {
error.value = e.message
}
}
</script>
<template>
<form @submit.prevent="entrar" style="display: grid; gap: 0.5rem; max-width: 320px">
<h1>Iniciar sesión</h1>
<input v-model="email" type="email" placeholder="Email" required>
<input v-model="password" type="password" placeholder="Contraseña" required>
<p v-if="error" role="alert" style="color: crimson">{{ error }}</p>
<button :disabled="auth.cargando">{{ auth.cargando ? 'Entrando…' : 'Entrar' }}</button>
</form>
</template>(El store useAuthStore lo construirás en la lección de Pinia.)
Seguridad
Los guards sólo controlan qué pantallas ve el usuario. Cualquiera puede leer el JavaScript de tu app. Los datos privados deben protegerse en el backend, verificando el token o la sesión en cada petición (ver Autenticación en Node).
Despliegue: el problema del 404
Con createWebHistory, si alguien entra directo a https://misitio.com/productos/15, el servidor busca ese archivo y responde 404. Hay que configurar el servidor para devolver siempre index.html. Es igual que en una SPA de React: revisa los ejemplos para Netlify, Vercel, Apache (.htaccess) y Nginx en Despliegue de React.
Si no puedes configurar el servidor, usa createWebHashHistory(): las URLs quedan como misitio.com/#/productos/15 y funcionan en cualquier hosting (aunque son menos amigables para SEO).
Buena práctica
- Carga diferida (
() => import(...)) para todas las vistas excepto la de inicio: reduce el JavaScript inicial. - Usa rutas con nombre y
metapara títulos, permisos y layouts. - Si el SEO es importante, considera Nuxt, que genera el HTML en el servidor.
Ejercicios
- Crea una ruta
/blogcon la lista de posts dehttps://jsonplaceholder.typicode.com/posts?_limit=10y/blog/:idcon el detalle, usandoprops: truey carga diferida. - Agrega
meta: { titulo: '...' }a tus rutas y unafterEachque actualicedocument.title. Para el detalle del blog, actualiza el título con el nombre del post una vez cargado.
Ver solución
js
// 1. router/index.js (rutas)
{ path: '/blog', name: 'blog', component: () => import('@/views/BlogView.vue'), meta: { titulo: 'Blog' } },
{ path: '/blog/:id', name: 'post', component: () => import('@/views/PostView.vue'), props: true, meta: { titulo: 'Artículo' } }vue
<!-- views/BlogView.vue -->
<script setup>
import { ref, onMounted } from 'vue'
const posts = ref([])
onMounted(async () => {
posts.value = await (await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10')).json()
})
</script>
<template>
<h1>Blog</h1>
<ul>
<li v-for="p in posts" :key="p.id">
<RouterLink :to="{ name: 'post', params: { id: p.id } }">{{ p.title }}</RouterLink>
</li>
</ul>
</template>vue
<!-- views/PostView.vue -->
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({ id: String })
const post = ref(null)
watch(
() => props.id,
async (id) => {
post.value = null
post.value = await (await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)).json()
document.title = `${post.value.title} | MiTienda` // 2. título dinámico
},
{ immediate: true }
)
</script>
<template>
<RouterLink to="/blog">← Blog</RouterLink>
<p v-if="!post">Cargando…</p>
<article v-else>
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>
</article>
</template>js
// 2. router/index.js
router.afterEach((to) => {
document.title = to.meta.titulo ? `${to.meta.titulo} | MiTienda` : 'MiTienda'
})Resumen
createRouter+createWebHistorydefinen las rutas;RouterViewmuestra la actual yRouterLinknavega.- Parámetros (
:id) conprops: trueouseRoute().params; filtros conroute.query. useRouter()para navegar desde código (push,replace,back).- Rutas anidadas con
childreny unRouterViewen el layout. - Guards (
beforeEach) +metapara rutas protegidas; la seguridad real va en el backend.