Saltar al contenido principal

Navegación por eventos

Navegar en una aplicación web consiste en cambiar de ruta y actualizar la vista sin recargar la página completa en el navegador. En Next.js (App Router), la navegación puede iniciarse de forma declarativa desde el cliente o de forma programática tanto en componentes cliente como en el servidor. En la Semana 9 vimos la estructura básica de carpetas y rutas; ahora profundizaremos en cómo activar transiciones mediante eventos e interacciones del usuario.


Formas de navegación​

Next.js ofrece distintas herramientas para cambiar de ruta o modificar la URL según el contexto de ejecución:

MecanismoEntornoCuándo usarloEjemplo
<Link>Cliente y ServidorEnlaces estáticos y menús visibles en pantalla.<Link href="/productos">Catálogo</Link>
router.push()Cliente ('use client')Navegar tras una acción o evento del usuario.router.push('/productos')
router.replace()Cliente ('use client')Redirigir sin guardar la ruta actual en el historial.router.replace('/dashboard')
router.back()Cliente ('use client')Retroceder a la página anterior en el historial.router.back()
router.forward()Cliente ('use client')Avanzar a la página siguiente en el historial.router.forward()
router.refresh()Cliente ('use client')Refrescar datos del servidor sin perder estado local.router.refresh()
router.prefetch()Cliente ('use client')Precargar una ruta en segundo plano antes de navegar.router.prefetch('/productos')
redirect()Servidor (y render de cliente)Redireccionar antes de renderizar la página o en una acción. No sirve dentro de manejadores de eventos.redirect('/login')
permanentRedirect()ServidorRedireccionar de forma permanente (código HTTP 308).permanentRedirect('/nuevo-catalogo')
notFound()ServidorMostrar la vista 404 cuando un registro no existe.notFound()
pushState / replaceStateCliente ('use client')Cambiar la URL sin navegar ni pedir la página al servidor.window.history.pushState(null, '', '?orden=asc')

El componente <Link> es la forma declarativa estándar de navegación en Next.js. Renderiza una etiqueta <a> accesible y optimiza la carga precargando rutas en segundo plano.

Uso básico y rutas dinámicas​

Para rutas fijas o dinámicas con cadenas de plantilla:

components/MenuNavegacion.tsx
import Link from 'next/link';

export function MenuNavegacion({ productoId }: { productoId: string }) {
return (
<nav className="flex gap-4">
<Link href="/productos">Todos los productos</Link>
<Link href={`/productos/${productoId}`}>Detalle del producto</Link>
</nav>
);
}
  • href: la ruta de destino. Puede ser una cadena ("/productos" o una plantilla como `/productos/${id}`) o un objeto { pathname, query }: href={{ pathname: '/buscar', query: { q: 'tenis' } }} navega a /buscar?q=tenis.
  • replace: si es true, reemplaza la URL actual en el historial en vez de añadir una nueva entrada (replace={true}). Su valor por defecto es false.
  • scroll: controla si la pantalla sube al inicio tras navegar. Su valor por defecto es true. Con scroll={false} la posición vertical se conserva.
  • prefetch: controla la precarga en segundo plano cuando el enlace entra en el viewport. Su valor por defecto es "auto" (equivale a null): en una ruta estática precarga la ruta completa y en una ruta dinámica precarga hasta el loading.tsx más cercano. Con true precarga la ruta completa también en las rutas dinámicas, y con false nunca precarga.
  • onNavigate: función que se ejecuta durante la navegación del lado del cliente. Llamar a e.preventDefault() dentro de ella cancela la navegación. No se ejecuta con Ctrl/Cmd + clic ni con enlaces externos.
El prefetch solo funciona en producción

Durante el desarrollo (npm run dev) Next.js no precarga las rutas. Para ver el efecto de prefetch hay que compilar la aplicación con npm run build y ejecutarla con npm run start.

Enlace activo con usePathname​

Para resaltar el enlace de la página en la que se encuentra el usuario, se utiliza el hook usePathname:

components/NavLink.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';

export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
const pathname = usePathname();
const isActive = pathname === href;

return (
<Link href={href} className={isActive ? 'font-bold text-blue-600' : 'text-gray-600'}>
{children}
</Link>
);
}

Existen otras dos herramientas relacionadas con el estado de un enlace:

  • useSelectedLayoutSegment(): devuelve el segmento activo un nivel por debajo del layout donde se usa (por ejemplo, 'productos'). Es útil en un navbar dentro de un layout, porque el enlace permanece activo en todas las subrutas (/productos/42) y no solo en la ruta exacta.
  • useLinkStatus(): devuelve { pending }, que es true mientras la navegación del <Link> está en curso. Se importa desde next/link y debe usarse en un componente hijo del <Link>, por ejemplo para mostrar un indicador de carga.
components/IndicadorEnlace.tsx
'use client';

import { useLinkStatus } from 'next/link';

export function IndicadorEnlace() {
const { pending } = useLinkStatus();
return <span>{pending ? 'Cargando…' : 'Ir'}</span>;
}

// Uso: <Link href="/productos"><IndicadorEnlace /></Link>

El hook useRouter​

El hook useRouter permite navegar de forma programática en respuesta a eventos de JavaScript.

Requiere la directiva 'use client' porque usa el historial del navegador, que solo existe en el cliente.

Métodos disponibles​

  • router.push(href, options?): navega a la ruta indicada y añade una entrada a la pila del historial.
  • router.replace(href, options?): navega a la ruta indicada reemplazando la entrada actual del historial.
  • router.back(): retrocede una posición en el historial de navegación.
  • router.forward(): avanza una posición en el historial de navegación.
  • router.refresh(): solicita de nuevo los Server Components al servidor sin reiniciar el estado local del cliente (por ejemplo, el contenido de un useState o la posición del scroll). Limpia la caché del cliente de la ruta actual, pero no invalida la caché del servidor: para eso se usan revalidatePath o revalidateTag.
  • router.prefetch(href): precarga manualmente el código y los datos de una ruta en segundo plano, por ejemplo router.prefetch('/productos').

Tanto push como replace aceptan un segundo argumento con opciones; la más usada es { scroll: false }, que conserva la posición vertical de la página:

Opciones de router.push
router.push('/productos', { scroll: false });
No pases URLs sin validar

router.push y router.replace no deben recibir URLs que provengan de la entrada del usuario sin validarlas: una URL con el esquema javascript: se ejecutaría en el contexto de tu página.

De next/router a next/navigation

En el App Router, pathname y query de next/router se reemplazan por usePathname() y useSearchParams(), y router.events ya no existe: para reaccionar a un cambio de ruta se combinan ambos hooks dentro de un useEffect.

El caso más directo ocurre al hacer clic sobre un botón o tarjeta:

components/BotonVerProducto.tsx
'use client';

import { useRouter } from 'next/navigation';

export function BotonVerProducto({ id }: { id: string }) {
const router = useRouter();

return (
<button type="button" onClick={() => router.push(`/productos/${id}`)}>
Ver producto
</button>
);
}

En un formulario, la navegación debe ejecutarse solo cuando la operación asíncrona concluye con éxito:

components/FormularioProducto.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';

export function FormularioProducto() {
const router = useRouter();
const [nombre, setNombre] = useState('');
const [error, setError] = useState<string | null>(null);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);

const ok = await guardar({ nombre });
if (ok) {
router.push('/productos');
} else {
setError('No se pudo guardar el producto.');
}
};

return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && <p className="text-red-600">{error}</p>}
<input
type="text"
value={nombre}
onChange={(e) => setNombre(e.target.value)}
placeholder="Nombre del producto"
/>
<button type="submit">Guardar</button>
</form>
);
}

async function guardar(datos: { nombre: string }): Promise<boolean> {
return datos.nombre.trim().length > 0;
}

Explora en la siguiente animación interactiva cómo viaja el guardado entre el Usuario, el Componente cliente y el Router de Next.js. Activa el interruptor inferior para simular un guardado fallido:

Secuencia interactiva de navegación por eventos tras envío de formulario

El flujo anterior demuestra dos caminos:

  1. Éxito: la promesa resuelve favorablemente, el componente entra en el bloque if y ejecuta router.push('/productos'). Next.js monta la nueva vista.
  2. Fallo: la promesa devuelve false, entra en el bloque else, activa el estado de error y no llama a router.push(). El usuario conserva los textos ingresados.

push frente a replace​

La diferencia entre router.push y router.replace radica en cómo gestionan la pila del historial del navegador:

  • push: agrega una nueva URL encima de la pila. El botón "Atrás" regresa a la pantalla previa.
  • replace: sustituye la URL actual por la nueva sin agrandar la pila. El botón "Atrás" salta a la ruta anterior a la sustituida.
Pila del Historial del Navegador: push frente a replacePila del Historial del Navegador: push frente a replace

El caso típico de replace es el inicio de sesión. Una vez autenticado el usuario, redirigir con replace evita que al pulsar "Atrás" vuelva al formulario de login:

components/FormularioLogin.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';

export function FormularioLogin() {
const router = useRouter();
const [email, setEmail] = useState('');

const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const ok = await loginSimulado(email);
if (ok) {
router.replace('/dashboard');
}
};

return (
<form onSubmit={handleLogin}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit">Iniciar sesión</button>
</form>
);
}

async function loginSimulado(email: string): Promise<boolean> {
return email.includes('@');
}

Lectura de la URL​

Para leer rutas y parámetros, Next.js cuenta con utilidades en el cliente y en el servidor.

En el cliente​

En Client Components se utilizan tres hooks de next/navigation:

  • usePathname(): devuelve la ruta actual (por ejemplo, "/productos").
  • useParams(): devuelve un objeto con los segmentos dinámicos de la ruta (por ejemplo, { id: '42' }).
  • useSearchParams(): devuelve una instancia de solo lectura con los parámetros de consulta (por ejemplo, ?categoria=calzado).
Suspense y useSearchParams

Cualquier Client Component que utilice useSearchParams() debe estar envuelto dentro de un límite de <Suspense> para no desactivar el prerenderizado estático de la página.

Ejemplo de filtro reactivo que actualiza ?categoria= con router.push:

components/FiltroCategoria.tsx
'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';

export function FiltroCategoria() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();

const handleSelect = (categoria: string) => {
const params = new URLSearchParams(searchParams.toString());
if (categoria) {
params.set('categoria', categoria);
} else {
params.delete('categoria');
}
const query = params.toString();
router.push(query ? `${pathname}?${query}` : pathname, { scroll: false });
};

return (
<select onChange={(e) => handleSelect(e.target.value)}>
<option value="">Todas</option>
<option value="ropa">Ropa</option>
<option value="calzado">Calzado</option>
</select>
);
}

En el servidor​

En Next.js 16, las propiedades params y searchParams que reciben las páginas de servidor son promesas (Promise) y deben resolverse con await:

app/productos/[id]/page.tsx
interface Props {
params: Promise<{ id: string }>;
searchParams: Promise<{ categoria?: string }>;
}

export default async function ProductoPage({ params, searchParams }: Props) {
const { id } = await params;
const { categoria } = await searchParams;

return (
<main>
<h1>Producto: {id}</h1>
<p>Categoría activa: {categoria || 'general'}</p>
</main>
);
}

En Server Components, Route Handlers y Server Actions no existe el hook useRouter. La navegación se gestiona mediante funciones de next/navigation:

  • redirect(path, type?): interrumpe el render y redirige a otra ruta. El segundo argumento opcional es 'replace' o 'push' y decide si la redirección reemplaza o agrega una entrada al historial. Por defecto usa push en las Server Actions y replace en el resto. En un Server Component el argumento no tiene efecto.
  • permanentRedirect(path, type?): igual que redirect, pero emite una redirección permanente con código de estado HTTP 308.
  • notFound(): cancela el render y muestra el archivo not-found.tsx más cercano.

redirect responde con el código 307 (y 303 cuando proviene de una Server Action enviada por un formulario). Puede llamarse en Server Components, Route Handlers y Server Actions, y también durante el render de un Client Component, pero no dentro de un manejador de eventos: allí se usa useRouter.

Ejemplo en Server Component y Server Action​

app/dashboard/page.tsx
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
const sesion = await verificarSesion();
if (!sesion) {
redirect('/login');
}

return <h1>Panel privado</h1>;
}

async function verificarSesion() {
return null;
}

En una Server Action tras guardar un formulario:

app/acciones.ts
'use server';

import { redirect } from 'next/navigation';

export async function crearProducto(formData: FormData) {
const nombre = formData.get('nombre') as string;
// Guardar en base de datos...
redirect('/productos');
}

Para un recurso inexistente:

app/productos/[id]/page.tsx
import { notFound } from 'next/navigation';

export default async function ProductoDetalle({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
if (id === '0' || id === 'inexistente') {
notFound();
}

return <h1>Producto {id}</h1>;
}
No captures redirecciones en try/catch

Tanto redirect() como notFound() lanzan errores internos (NEXT_REDIRECT y NEXT_NOT_FOUND) para detener la ejecución y transferir el control a Next.js. Si las colocas dentro de un bloque try/catch genérico, el bloque atrapará el error e impedirá que la redirección funcione. Si requieres capturar otros errores, llama a redirect fuera del try/catch.


Redirecciones fuera de los componentes​

No todas las redirecciones se escriben dentro de una página. Next.js ofrece dos lugares más, según la lógica que necesites:

  • redirects() en next.config.js: para rutas fijas que siempre redirigen a otra (por ejemplo, una ruta que cambió de nombre). Con permanent: true responde 308 y con false responde 307.
  • proxy.ts: para decidir la redirección con la información de cada petición (cookies, cabeceras, ruta). En Next.js 16 el antiguo archivo middleware.ts se renombró a proxy.ts y su función exportada se llama proxy.
next.config.js
module.exports = {
async redirects() {
return [
{ source: '/antiguo', destination: '/nuevo', permanent: true },
];
},
};
proxy.ts
import { NextResponse, type NextRequest } from 'next/server';

export function proxy(request: NextRequest) {
return NextResponse.redirect(new URL('/home', request.url));
}

// Solo se ejecuta en las rutas que coinciden con el matcher
export const config = { matcher: '/viejo/:path*' };

History API nativa​

En Client Components es posible usar directamente los métodos del navegador window.history.pushState y window.history.replaceState.

Esto permite modificar la URL en la barra de direcciones (por ejemplo, al ordenar una lista o abrir una pestaña secundaria) sin navegar ni volver a pedir la página al servidor. Next.js sincroniza automáticamente estas llamadas con usePathname y useSearchParams.

components/OrdenLista.tsx
'use client';

import { useSearchParams } from 'next/navigation';

export function OrdenLista() {
const searchParams = useSearchParams();

const cambiarOrden = (orden: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set('orden', orden);
window.history.pushState(null, '', `?${params.toString()}`);
};

return (
<div className="flex gap-2">
<button type="button" onClick={() => cambiarOrden('asc')}>Ascendente</button>
<button type="button" onClick={() => cambiarOrden('desc')}>Descendente</button>
</div>
);
}

Guía de decisión​

Aplica esta regla práctica para elegir el mecanismo adecuado:

  • Si el usuario hace clic en un menú o botón visible: usa <Link>.
  • Si la navegación depende de un evento o validación en el cliente: usa router.push.
  • Si completaste un flujo que no debe reabrirse (login o pasarela): usa router.replace.
  • Si rediriges desde el servidor por falta de sesión o permisos: usa redirect.
  • Si el identificador solicitado no existe en la base de datos: usa notFound.
  • Si solo actualizas parámetros en la URL sin recargar vistas: usa window.history.pushState.

Errores comunes​

ErrorCausaSolución
Importar de next/routerConfundir el paquete del Pages Router con el de App Router.Importar siempre useRouter desde next/navigation.
Llamar router.push durante el renderEjecutar la navegación directamente en el cuerpo de la función.Mover la llamada dentro de un evento (onClick, onSubmit) o un useEffect.
Usar <a> para rutas internasUtilizar anclas HTML nativas en lugar del componente <Link>.Usar <Link> para evitar recargas completas de página.
Olvidar 'use client' con useRouterIntentar ejecutar hooks interactivos en un Server Component.Añadir 'use client'; en la primera línea del archivo.

Cuestionario de Autoevaluación​

Cargando cuestionario...