En esta página

Vue Router 4 — navegación en SPA

15 min lectura TextoCap. 3 — Composición

¿Qué es Vue Router?

Vue Router es el enrutador oficial de Vue. Permite crear Single Page Applications (SPA) donde la navegación entre páginas ocurre en el cliente sin recargas completas del servidor. Vue Router 4 está diseñado específicamente para Vue 3 y aprovecha la Composition API.

Instalación y configuración básica

Vue Router ya viene incluido si seleccionaste "Router" al crear el proyecto con create-vue. Si necesitas instalarlo manualmente:

npm install vue-router@4

La configuración del router vive en src/router/index.ts y se registra como plugin en main.ts:

// main.ts
import { createApp } from 'vue'
import router from './router'
import App from './App.vue'

createApp(App).use(router).mount('#app')

<RouterView> es donde Vue Router renderiza el componente de la ruta activa. <RouterLink> es el componente de enlace que reemplaza a <a>:

<!-- App.vue -->
<template>
  <nav>
    <!-- RouterLink genera un <a> con la clase "router-link-active" cuando está activo -->
    <RouterLink to="/">Inicio</RouterLink>
    <RouterLink to="/productos">Productos</RouterLink>
    <RouterLink :to="{ name: 'usuario', params: { id: 42 } }">Perfil</RouterLink>

    <!-- active-class personaliza la clase CSS cuando el enlace está activo -->
    <RouterLink to="/admin" active-class="activo">Admin</RouterLink>
  </nav>

  <!-- Aquí se renderiza el componente de la ruta activa -->
  <RouterView />
</template>

Parámetros de ruta

Los parámetros dinámicos se definen con :nombre en la ruta:

// En el router
{
  path: '/usuarios/:id',
  name: 'usuario-detalle',
  component: () => import('@/views/UsuarioDetalle.vue'),
  props: true, // Pasa params como props al componente
}
<!-- UsuarioDetalle.vue con props: true -->
<script setup lang="ts">
// El param 'id' llega como prop cuando props: true está en la ruta
const { id } = defineProps<{ id: string }>()
</script>

<!-- O accediendo via useRoute() -->
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const id = route.params['id'] as string
</script>

Parámetros opcionales y comodín

const rutas = [
  { path: '/buscar/:termino?', component: Buscar },  // ? = opcional
  { path: '/:pathMatch(.*)*', component: NotFound },  // Comodín 404
]

Query strings y hash

<script setup lang="ts">
import { useRoute } from 'vue-router'

const route = useRoute()

// URL: /productos?categoria=electronica&orden=precio#seccion
const categoria = route.query['categoria'] as string | undefined
const orden = route.query['orden'] as string | undefined
const seccion = route.hash // '#seccion'
</script>

Navegar con query:

router.push({
  name: 'productos',
  query: { categoria: 'electronica', orden: 'precio' },
  hash: '#seccion'
})

Rutas anidadas

Las rutas anidadas permiten layouts con sub-vistas:

{
  path: '/cuenta',
  component: CuentaLayout,  // Tiene su propio <RouterView>
  children: [
    { path: '', component: CuentaOverview },         // /cuenta
    { path: 'perfil', component: CuentaPerfil },     // /cuenta/perfil
    { path: 'seguridad', component: CuentaSeguridad } // /cuenta/seguridad
  ]
}
<!-- CuentaLayout.vue -->
<template>
  <div class="cuenta-layout">
    <nav>
      <RouterLink to="/cuenta">Overview</RouterLink>
      <RouterLink to="/cuenta/perfil">Perfil</RouterLink>
      <RouterLink to="/cuenta/seguridad">Seguridad</RouterLink>
    </nav>
    <main>
      <RouterView /> <!-- Renderiza la ruta hija activa -->
    </main>
  </div>
</template>

Los guardias permiten controlar si una navegación puede proceder:

Guard en componente

<script setup lang="ts">
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

const tienesCambiosGuardados = ref(false)

// Se ejecuta antes de salir de este componente
onBeforeRouteLeave((_to, _from, next) => {
  if (tienesCambiosGuardados.value) {
    const confirmar = window.confirm('¿Salir sin guardar los cambios?')
    next(confirmar) // next(true) continúa, next(false) cancela
  } else {
    next()
  }
})

// Se ejecuta cuando la ruta cambia pero el componente se reutiliza (ej: cambio de :id)
onBeforeRouteUpdate(async (to) => {
  const nuevoId = to.params['id'] as string
  await cargarProducto(nuevoId)
})
</script>

Guard por ruta

{
  path: '/premium',
  component: PremiumView,
  beforeEnter: (to, from) => {
    if (!usuarioTienePlan('premium')) {
      return { name: 'planes' }
    }
  }
}

Lazy loading y chunks con nombre

const rutas = [
  {
    path: '/admin',
    // webpackChunkName (también funciona con Vite) agrupa en un chunk
    component: () => import(/* webpackChunkName: "admin" */ '@/views/AdminView.vue'),
  },
  {
    path: '/admin/reportes',
    component: () => import(/* webpackChunkName: "admin" */ '@/views/ReportesView.vue'),
  },
]
import { useRouter } from 'vue-router'

const router = useRouter()

// Navegar a una ruta
router.push('/productos')
router.push({ name: 'producto-detalle', params: { id: '42' } })
router.push({ path: '/buscar', query: { q: 'vue' } })

// Reemplazar la entrada actual en el historial (no añade al historial)
router.replace({ name: 'inicio' })

// Navegar relativo al historial
router.back()      // Equivale a history.back()
router.forward()   // Equivale a history.forward()
router.go(-2)      // Ir 2 pasos atrás en el historial
<template>
  <!-- exact — solo activo en la ruta exacta (no en subrutas) -->
  <RouterLink to="/" :exact="true">Inicio</RouterLink>

  <!-- Slot por defecto — acceso al estado isActive -->
  <RouterLink to="/productos" custom v-slot="{ navigate, isActive, href }">
    <a
      :href="href"
      :class="{ 'mi-clase-activa': isActive }"
      @click="navigate"
    >
      Productos
    </a>
  </RouterLink>
</template>

Meta tipado con TypeScript

Para tipar route.meta, extiende el módulo de Vue Router:

// src/router/types.d.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    requiereAuth?: boolean
    roles?: ('admin' | 'editor' | 'usuario')[]
    titulo?: string
    descripcion?: string
  }
}

Ahora to.meta.requiereAuth está completamente tipado.


Práctica

  1. SPA con 3 rutas: Crea un proyecto con rutas para Inicio, Blog (con lista de posts) y Detalle del post (/blog/:slug). Usa lazy loading en todas las vistas y un <RouterLink> con active-class en el nav.
  2. Guard de autenticación: Implementa un guard global beforeEach que rediriga a /login si la ruta tiene meta.requiereAuth: true y el usuario no está autenticado. Guarda la URL original en query.redirect para redirigir después del login.
  3. Rutas anidadas con layout: Crea un layout de dashboard con navegación lateral y subrutas (/dashboard, /dashboard/perfil, /dashboard/configuracion). Cada subvista tiene su propio componente renderizado en el <RouterView> del layout.

En la siguiente lección aprenderemos Pinia, la solución oficial de gestión de estado global para Vue.

Lazy loading de rutas — siempre usa import()
Cada ruta debería usar import() dinámico para el componente. Esto permite a Vite dividir el bundle en chunks: el código de cada vista solo se descarga cuando el usuario navega a ella. El resultado es un tiempo de carga inicial mucho más rápido.
useRoute y useRouter solo dentro de setup()
Los composables useRoute() y useRouter() solo pueden llamarse dentro de setup() o script setup. Si necesitas el router fuera del contexto de un componente (en un guard, en un store de Pinia), importa el router directamente desde '@/router'.
meta tipado con TypeScript
Para tipar las propiedades de route.meta en TypeScript, debes declarar el módulo vue-router. Añade un archivo router.d.ts con: declare module 'vue-router' { interface RouteMeta { requiereAuth?: boolean; titulo?: string } }. Esto da autocompletado en to.meta.
typescript
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'

const rutas: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'inicio',
    // Lazy loading — el chunk solo se carga cuando se navega a esta ruta
    component: () => import('@/views/InicioView.vue'),
  },
  {
    path: '/productos',
    name: 'productos',
    component: () => import('@/views/ProductosView.vue'),
  },
  {
    path: '/productos/:id',
    name: 'producto-detalle',
    component: () => import('@/views/ProductoDetalleView.vue'),
    // Proporcionar params como props al componente
    props: true,
  },
  {
    path: '/admin',
    component: () => import('@/views/AdminLayout.vue'),
    meta: { requiereAuth: true, roles: ['admin'] },
    // Rutas anidadas
    children: [
      {
        path: '',          // /admin
        name: 'admin-dashboard',
        component: () => import('@/views/AdminDashboard.vue'),
      },
      {
        path: 'usuarios', // /admin/usuarios
        name: 'admin-usuarios',
        component: () => import('@/views/AdminUsuarios.vue'),
      },
    ],
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: () => import('@/views/NotFoundView.vue'),
  },
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: rutas,
  scrollBehavior(to, _from, savedPosition) {
    if (savedPosition) return savedPosition
    if (to.hash) return { el: to.hash, behavior: 'smooth' }
    return { top: 0 }
  },
})

export default router
import type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

export function registrarGuards(router: Router): void {
  // Guard global — se ejecuta antes de cada navegación
  router.beforeEach(async (to, _from) => {
    const auth = useAuthStore()

    // Esperar a que la autenticación esté lista
    if (!auth.listo) await auth.inicializar()

    const requiereAuth = to.meta.requiereAuth === true
    const rolesPermitidos = to.meta.roles as string[] | undefined

    // Redirigir al login si no está autenticado
    if (requiereAuth && !auth.usuario) {
      return { name: 'login', query: { redirect: to.fullPath } }
    }

    // Verificar rol
    if (rolesPermitidos && auth.usuario) {
      const tieneRol = rolesPermitidos.includes(auth.usuario.rol)
      if (!tieneRol) {
        return { name: 'no-autorizado' }
      }
    }

    // Continuar la navegación (devolver undefined o true)
  })

  // Guard global de post-navegación
  router.afterEach((to) => {
    document.title = (to.meta.titulo as string | undefined) ?? 'Mi App'
  })
}