Skip to content

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-router

Configuració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 router
js
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>
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 router
vue
<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 meta para títulos, permisos y layouts.
  • Si el SEO es importante, considera Nuxt, que genera el HTML en el servidor.

Ejercicios ​

  1. Crea una ruta /blog con la lista de posts de https://jsonplaceholder.typicode.com/posts?_limit=10 y /blog/:id con el detalle, usando props: true y carga diferida.
  2. Agrega meta: { titulo: '...' } a tus rutas y un afterEach que actualice document.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 + createWebHistory definen las rutas; RouterView muestra la actual y RouterLink navega.
  • Parámetros (:id) con props: true o useRoute().params; filtros con route.query.
  • useRouter() para navegar desde código (push, replace, back).
  • Rutas anidadas con children y un RouterView en el layout.
  • Guards (beforeEach) + meta para rutas protegidas; la seguridad real va en el backend.

Academia Web — aprende, copia y construye.