Angular Router: A Practical Guide to Everything You Need to Know

Kevin Dávila
El router de Angular es una de las piezas más poderosas del framework. También es una de las más mal usadas.
Hoy exploraremos desde los fundamentos hasta los detalles que marcan la diferencia en una app real.
Por qué el router importa más de lo que parece
Una Single Page Application (SPA) tiene un problema de raíz: el servidor solo entrega un archivo HTML. El navegador no recarga la página. Pero los usuarios esperan que la URL cambie, que el botón de atrás funcione, y que puedan compartir un link que lleve exactamente a donde estaban.
El router de Angular resuelve eso. Intercepta la navegación, actualiza la URL en el historial del navegador y renderiza el componente correcto, todo sin salir de la página.
Cómo funciona por dentro
Cuando configuras una ruta, Angular no hace magia. Registra un conjunto de reglas: "si la URL coincide con este patrón, muestra este componente". Eso es el Routes array.
El router escucha los cambios en la URL usando la API History del navegador (pushState, replaceState) o, en apps legacy, el hash (#). Cuando detecta un cambio, recorre tu árbol de rutas de arriba hacia abajo hasta encontrar una coincidencia.
Después ejecuta una pipeline en orden:
Reconoce la URL y construye el árbol de rutas activadas
Ejecuta los guards (CanActivate, CanMatch, etc.)
Ejecuta los resolvers si los hay
Activa los componentes en los <router-outlet> correspondientes
Actualiza la URL y dispara los router events
Todo esto ocurre de forma reactiva. Si usas signals o RxJS, puedes engancharte a cualquier punto de esta pipeline.
Setup básico
En Angular moderno (v17+) con standalone components, el setup es muy simple
// app.routes.ts
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { AboutComponent } from './about/about.component';
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'about', component: AboutComponent },
{ path: '**', redirectTo: '' },
];
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [provideRouter(routes)],
});
<!-- app.component.html -->
<nav>
<a routerLink="/">Inicio</a>
<a routerLink="/about">Acerca de</a>
</nav>
<router-outlet />
Sin módulos, sin RouterModule.forRoot(). Solo provideRouter y listo.
Lazy Loading
Por defecto, Angular empaqueta todo en un solo bundle. Cuando la app crece, eso se vuelve un problema: el usuario descarga código que quizás nunca va a usar.
Lazy loading soluciona eso. Carga el código de una ruta solo cuando el usuario la visita.
// app.routes.ts
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'dashboard',
loadComponent: () =>
import('./dashboard/dashboard.component').then(
(m) => m.DashboardComponent
),
},
{
path: 'admin',
loadChildren: () =>
import('./admin/admin.routes').then((m) => m.adminRoutes),
},
];
// admin/admin.routes.ts
import { Routes } from '@angular/router';
export const adminRoutes: Routes = [
{ path: '', component: AdminHomeComponent },
{ path: 'users', component: AdminUsersComponent },
{ path: 'settings', component: AdminSettingsComponent },
];
loadComponent es para un solo componente. loadChildren es para un módulo de rutas completo. Usa loadChildren cuando tienes un área entera de la app (como admin, checkout, o perfil de usuario).
El resultado: tu bundle inicial es más pequeño y la app carga más rápido.
Rutas con parámetros
La mayoría de las apps necesitan rutas dinámicas. Un blog tiene /post/slug-del-post. Un e-commerce tiene /producto/123.
// app.routes.ts
{
path: 'productos/:id',
loadComponent: () =>
import('./producto/producto.component').then((m) => m.ProductoComponent),
}
Para leer ese parámetro en el componente, tienes dos enfoques. El moderno usa input():
// producto.component.ts
import { Component, input, OnInit } from '@angular/core';
import { ProductoService } from '../services/producto.service';
@Component({
selector: 'app-producto',
standalone: true,
template: `
<h1>{{ producto()?.nombre }}</h1>
`,
})
export class ProductoComponent {
id = input.required<string>();
constructor(private productoService: ProductoService) {}
}
Para que input() funcione con parámetros de ruta, hay que activarlo en provideRouter:
// main.ts
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withComponentInputBinding()),
],
});
Con withComponentInputBinding(), Angular mapea automáticamente los parámetros de ruta, query params, y datos estáticos como inputs del componente. Es limpio y evita inyectar ActivatedRoute en todas partes.
Rutas anidadas (nested routes)
Cuando una sección de tu app tiene su propio layout, necesitas rutas anidadas. Un ejemplo típico: un dashboard con sidebar que permanece fijo mientras el contenido central cambia.
// app.routes.ts
{
path: 'dashboard',
component: DashboardLayoutComponent, // tiene su propio <router-outlet>
children: [
{ path: '', component: DashboardHomeComponent },
{ path: 'reportes', component: ReportesComponent },
{ path: 'configuracion', component: ConfiguracionComponent },
],
}
<!-- dashboard-layout.component.html -->
<div class="dashboard">
<app-sidebar />
<main>
<router-outlet /> <!-- aquí se renderizan los children -->
</main>
</div>
El DashboardLayoutComponent actúa como contenedor. El <router-outlet> dentro de él renderiza el componente hijo según la URL. El sidebar siempre está presente.
Guards: controlar el acceso a las rutas
Un guard es una función que Angular ejecuta antes de activar una ruta. Si retorna true, la navegación continúa. Si retorna false o una URL, la cancela o redirige.
// guards/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) {
return true;
}
return router.createUrlTree(['/login']);
};
// app.routes.ts
{
path: 'dashboard',
canActivate: [authGuard],
loadChildren: () =>
import('./dashboard/dashboard.routes').then((m) => m.dashboardRoutes),
}
Los guards modernos son funciones, no clases. Más simples, más fáciles de testear.
Tipos de guards que debes conocer
canActivate: ¿puede el usuario entrar a esta ruta?
canActivateChild: ¿puede entrar a los hijos de esta ruta?
canDeactivate: ¿puede salir? - Útil para formularios con cambios sin guardar.
canMatch: decide si esta definición de ruta aplica. Útil para mostrar distintas versiones de una ruta según el rol.
// guards/unsaved-changes.guard.ts
import { CanDeactivateFn } from '@angular/router';
export interface HasUnsavedChanges {
hasUnsavedChanges(): boolean;
}
export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) => {
if (component.hasUnsavedChanges()) {
return confirm('Tienes cambios sin guardar. ¿Quieres salir de todas formas?');
}
return true;
};
Resolvers: datos antes de renderizar
Un resolver carga datos antes de que el componente aparezca. Esto evita que el usuario vea una pantalla vacía mientras se carga la información.
// resolvers/producto.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { ProductoService } from '../services/producto.service';
import { Producto } from '../models/producto.model';
export const productoResolver: ResolveFn<Producto> = (route) => {
const productoService = inject(ProductoService);
return productoService.getById(route.paramMap.get('id')!);
};
// app.routes.ts
{
path: 'productos/:id',
component: ProductoComponent,
resolve: { producto: productoResolver },
}
Con withComponentInputBinding() activo, el dato resuelto llega directo como input:
// producto.component.ts
export class ProductoComponent {
producto = input.required<Producto>();
}
Sin resolver, tendrías que gestionar el estado de carga manualmente. Con resolver, el componente asume que el dato ya está disponible.
El trade-off: el usuario espera antes de ver el componente. Para datos que se cargan rápido, está bien. Para llamadas lentas, puede ser mejor mostrar un skeleton y cargar en paralelo.
Router Events: saber qué está pasando
El router emite eventos a lo largo de cada navegación. Puedes escucharlos para, por ejemplo, mostrar una barra de progreso.
// app.component.ts
import { Component, inject } from '@angular/core';
import { Router, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router';
import { filter } from 'rxjs/operators';
@Component({
selector: 'app-root',
standalone: true,
template: `
@if (loading()) {
<div class="progress-bar"></div>
}
<router-outlet />
`,
})
export class AppComponent {
loading = signal(false);
private router = inject(Router);
constructor() {
this.router.events.pipe(
filter(event =>
event instanceof NavigationStart ||
event instanceof NavigationEnd ||
event instanceof NavigationCancel ||
event instanceof NavigationError
)
).subscribe(event => {
this.loading.set(event instanceof NavigationStart);
});
}
}
Los eventos más útiles:
Evento | Cuándo ocurre |
|---|---|
| Inicia la navegación |
| Se reconoció la ruta |
| Empiezan los guards |
| Empiezan los resolvers |
| Navegación completada |
| Guard canceló la navegación |
| Error durante la navegación |
Buenas prácticas
Organiza las rutas por feature, no por tipo. En lugar de tener un solo app.routes.ts con 40 rutas, divide en dashboard.routes.ts, auth.routes.ts, admin.routes.ts. Tu equipo te lo agradecerá.
Siempre define una ruta wildcard. La ruta ** captura cualquier URL que no coincida con ninguna otra. Sin ella, una URL mal escrita simplemente no muestra nada.
{ path: '**', redirectTo: '/404' }
Usa canMatch para A/B testing o roles. Si necesitas mostrar componentes distintos para distintos tipos de usuario en la misma URL, canMatch es más limpio que meter lógica dentro del componente.
Activa withComponentInputBinding() si usas Angular 16+. Elimina la necesidad de inyectar ActivatedRoute y hace tus componentes más fáciles de testear (puedes pasarles los parámetros directamente como inputs).
Prefetch con withPreloading. Si lazy loading, puedes precargar módulos en segundo plano para que la primera navegación sea instantánea:
import { withPreloading, PreloadAllModules } from '@angular/router';
provideRouter(routes, withPreloading(PreloadAllModules))
O una estrategia personalizada que solo precargue ciertos módulos.
Lo que NO debes hacer
No pongas lógica de negocio en los guards. Un guard solo debería responder a la pregunta "¿puede el usuario acceder?". Si empieza a hacer llamadas a la API para cargar datos, eso pertenece a un resolver.
No uses ActivatedRoute si puedes evitarlo. Con withComponentInputBinding() activo, los parámetros llegan como inputs. Inyectar ActivatedRoute en cada componente acopla el componente al router innecesariamente.
No navegues programáticamente desde el template. Esto:
<!-- Mal -->
<button (click)="router.navigate(['/home'])">Ir a inicio</button>
Es mejor así:
<!-- Bien -->
<a routerLink="/home">Ir a inicio</a>
Los links son accesibles, se pueden abrir en nueva pestaña, y el browser los entiende. La navegación programática es para casos donde la lógica determina a dónde ir.
No anides rutas sin un <router-outlet> en el padre. Si defines children pero el componente padre no tiene <router-outlet>, los componentes hijos nunca se van a renderizar. Es uno de los bugs más comunes y frustrantes.
No ignores el manejo de errores en resolvers. Si tu resolver lanza un error, la navegación falla silenciosamente. Siempre maneja el error:
export const productoResolver: ResolveFn<Producto | null> = (route) => {
const productoService = inject(ProductoService);
const router = inject(Router);
return productoService.getById(route.paramMap.get('id')!).pipe(
catchError(() => {
router.navigate(['/not-found']);
return of(null);
})
);
};
Casos de uso reales
App de e-commerce:
/ → landing page (eager)
/productos → catálogo (lazy)
/productos/:slug → detalle de producto (lazy, con resolver)
/carrito → carrito (lazy, con guard para login)
/checkout → flujo de pago (lazy, con guard + canDeactivate)
/cuenta → área de usuario (lazy, rutas anidadas)
Panel de administración:
/admin → redirige a /admin/dashboard con canMatch que verifica rol admin
/admin/dashboard → overview
/admin/usuarios → tabla de usuarios
/admin/usuarios/:id → editar usuario (con resolver para cargar el usuario)
** → página 404 personalizada
Conclusión
El router de Angular no es solo "configurar URLs". Es el sistema que define cómo fluye el usuario por tu app, qué código se carga y cuándo, quién puede acceder a qué, y qué datos están disponibles antes de que el usuario vea algo.
Cuando lo usas bien, la app se siente rápida, predecible y segura. Cuando lo usas mal, terminas con bundles enormes, componentes llenos de lógica de navegación y bugs difíciles de rastrear.
La clave está en entender la pipeline: URL → guards → resolvers → componentes. Si tienes ese modelo mental claro, el resto es detalles de implementación.
Keywords
Angular Router
Lazy Loading Angular
Angular Route Guards
Angular Resolvers
Angular Standalone Routing