Varias páginas sin recargar el navegador
Muchas de las aplicaciones web que usamos a diario parecen sitios con varias páginas: una portada, un catálogo, la ficha de un producto, el carrito, el perfil de usuario. Cada una tiene su propia URL, el botón de atrás del navegador funciona y podemos compartir un enlace directo a cualquiera de ellas. Sin embargo, muchas de esas aplicaciones son en realidad una única página HTML que JavaScript transforma según la dirección que aparece en la barra del navegador. Son las llamadas aplicaciones de página única, o SPA.
En una SPA, cambiar de sección no implica pedir al servidor un documento nuevo. La aplicación intercepta la navegación, actualiza la URL y muestra los componentes correspondientes. El resultado es una experiencia más rápida y fluida, porque solo cambia lo que tiene que cambiar.
React, por sí solo, no incluye ningún sistema para asociar URLs con componentes. Para eso existe React Router, la librería de enrutamiento más utilizada del ecosistema. Su versión 8, publicada en junio de 2026, se presentó como una versión deliberadamente tranquila, con pocos cambios incompatibles: entre ellos, la distribución exclusiva como módulos ES y una configuración por defecto para el middleware. Las versiones 6 de React Router y 2 de Remix han llegado al final de su ciclo de soporte, así que los proyectos que aún las usan deberían planificar su migración.
En esta guía construiremos el sitio de una pequeña tienda de lanas para aprender a instalar React Router, definir rutas, crear enlaces, leer parámetros de la URL, anidar diseños, mostrar una página de error, navegar desde el código y cargar datos antes de mostrar una ruta.
Qué ha cambiado respecto a las versiones antiguas
Si has visto tutoriales de hace algunos años, encontrarás código como este, propio de React Router 5:
import { BrowserRouter, Switch, Route } from "react-router-dom";
<Switch>
<Route path="/" component={Inicio} exact />
<Route path="/tienda" component={Tienda} />
<Route component={Error} />
</Switch>;
Ese código ya no funciona con las versiones actuales. Los cambios principales, que se introdujeron en la versión 6 y se mantienen desde entonces, son estos:
Switchse sustituyó porRoutes, que elige automáticamente la ruta más específica.- La propiedad
componentse sustituyó porelement, que recibe un elemento JSX ya creado. - La propiedad
exactdesapareció: todas las rutas coinciden de forma exacta por defecto. - La ruta de respaldo para direcciones desconocidas se declara con
path="*". useHistoryse sustituyó poruseNavigate.
Además, desde la versión 7 el paquete principal es react-router. El paquete react-router-dom se mantuvo durante un tiempo como simple reexportación para facilitar la migración, pero en proyectos nuevos se importa todo desde react-router.
Crear el proyecto e instalar React Router
Partimos de un proyecto de React creado con Vite:
npm create vite@latest tienda-lanas -- --template react
cd tienda-lanas
npm install
npm install react-router
npm run dev
La estructura que construiremos es esta:
tienda-lanas
├── index.html
├── package.json
└── src
├── main.jsx
├── App.jsx
├── componentes
│ └── BarraNavegacion.jsx
├── datos
│ └── productos.js
└── paginas
├── Inicio.jsx
├── Nosotros.jsx
├── Tienda.jsx
├── DetalleProducto.jsx
└── NoEncontrada.jsx
React Router puede usarse de tres formas, que la documentación llama modos: el modo declarativo, en el que las rutas se escriben como componentes JSX; el modo de datos, en el que se definen como objetos y pueden cargar datos antes de renderizarse; y el modo framework, que añade un plugin de Vite con enrutamiento basado en archivos y renderizado en el servidor. Empezaremos por el modo declarativo, el más sencillo, y después veremos el de datos.
Envolver la aplicación en el enrutador
El primer paso es envolver toda la aplicación en BrowserRouter. Este componente escucha los cambios en la URL del navegador y proporciona la información de la ruta actual a todos los componentes que contiene:
// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
Cualquier componente que use rutas, enlaces o hooks de navegación debe estar dentro de este enrutador. Si no lo está, React Router lanzará un error, como veremos en la sección de problemas frecuentes.
Definir las rutas
Las rutas se declaran con los componentes Routes y Route. Cada Route asocia una ruta de la URL, en la propiedad path, con el elemento que debe mostrarse, en la propiedad element:
// src/App.jsx
import { Routes, Route } from "react-router";
import Inicio from "./paginas/Inicio.jsx";
import Nosotros from "./paginas/Nosotros.jsx";
import Tienda from "./paginas/Tienda.jsx";
import NoEncontrada from "./paginas/NoEncontrada.jsx";
export default function App() {
return (
<Routes>
<Route path="/" element={<Inicio />} />
<Route path="/nosotros" element={<Nosotros />} />
<Route path="/tienda" element={<Tienda />} />
<Route path="*" element={<NoEncontrada />} />
</Routes>
);
}
Routes examina la URL actual y renderiza solo la ruta que mejor coincide. No importa el orden en que las declares: React Router calcula cuál es la más específica. La ruta con path="*" coincide con cualquier dirección y, como es la menos específica, solo se usa cuando ninguna otra encaja. Es la forma de crear una página 404 en React Router.
Las páginas son componentes normales de React:
// src/paginas/Inicio.jsx
export default function Inicio() {
return (
<section>
<h1>Lanas del Norte</h1>
<p>Ovillos de lana merino, alpaca y algodón teñidos a mano.</p>
</section>
);
}
// src/paginas/NoEncontrada.jsx
import { Link } from "react-router";
export default function NoEncontrada() {
return (
<section>
<h1>Página no encontrada</h1>
<p>La dirección que buscas no existe o ha cambiado.</p>
<Link to="/">Volver al inicio</Link>
</section>
);
}
Navegar con Link y NavLink
Para moverse entre páginas no se usan enlaces <a href> normales, porque provocarían una recarga completa del documento y se perdería el estado de la aplicación. En su lugar se usa el componente Link, que genera un enlace accesible pero intercepta el clic y cambia la URL sin recargar:
import { Link } from "react-router";
<Link to="/tienda">Ver la tienda</Link>;
Para los menús de navegación es más útil NavLink, que sabe si su ruta está activa. Acepta una función en className o style que recibe un objeto con la propiedad isActive, y además añade automáticamente el atributo aria-current="page" al enlace activo, lo que mejora la accesibilidad:
// src/componentes/BarraNavegacion.jsx
import { NavLink } from "react-router";
const enlaces = [
{ to: "/", texto: "Inicio" },
{ to: "/tienda", texto: "Tienda" },
{ to: "/nosotros", texto: "Nosotros" },
];
export default function BarraNavegacion() {
return (
<nav aria-label="Principal">
{enlaces.map(({ to, texto }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) =>
isActive ? "enlace activo" : "enlace"
}
>
{texto}
</NavLink>
))}
</nav>
);
}
La propiedad end en el enlace de inicio evita que se marque como activo en todas las páginas, ya que todas las rutas empiezan por /. Con la clase activo puedes resaltar la sección actual con CSS:
.enlace {
padding: 0.5rem 1rem;
text-decoration: none;
color: #4a3a2a;
}
.enlace.activo {
font-weight: 700;
border-bottom: 2px solid currentColor;
}
Rutas anidadas y diseños compartidos
En casi todas las aplicaciones hay elementos comunes a todas las páginas: la cabecera, el menú y el pie. En lugar de repetirlos en cada componente, React Router permite crear un diseño compartido mediante rutas anidadas en React Router. La ruta padre renderiza la estructura común y un componente Outlet que marca dónde deben aparecer las rutas hijas:
// src/App.jsx
import { Routes, Route, Outlet } from "react-router";
import BarraNavegacion from "./componentes/BarraNavegacion.jsx";
import Inicio from "./paginas/Inicio.jsx";
import Nosotros from "./paginas/Nosotros.jsx";
import Tienda from "./paginas/Tienda.jsx";
import DetalleProducto from "./paginas/DetalleProducto.jsx";
import NoEncontrada from "./paginas/NoEncontrada.jsx";
function Diseno() {
return (
<>
<header>
<BarraNavegacion />
</header>
<main>
<Outlet />
</main>
<footer>Lanas del Norte · Envíos en 48 horas</footer>
</>
);
}
export default function App() {
return (
<Routes>
<Route element={<Diseno />}>
<Route index element={<Inicio />} />
<Route path="nosotros" element={<Nosotros />} />
<Route path="tienda">
<Route index element={<Tienda />} />
<Route path=":slug" element={<DetalleProducto />} />
</Route>
<Route path="*" element={<NoEncontrada />} />
</Route>
</Routes>
);
}
Hay varios conceptos nuevos en este código. La ruta del diseño no tiene path: es una ruta de diseño que solo aporta estructura. Las rutas con la propiedad index son las que se muestran en la URL exacta del padre, como / o /tienda. Las rutas hijas usan rutas relativas, sin barra inicial, que se concatenan con la del padre. Y :slug es un segmento dinámico, que veremos a continuación.
Las rutas resultantes son:
/ → Diseño + Inicio
/nosotros → Diseño + Nosotros
/tienda → Diseño + Tienda
/tienda/merino-azul → Diseño + DetalleProducto
/cualquier-otra → Diseño + NoEncontrada
Parámetros dinámicos con useParams
La ficha de cada producto comparte el mismo componente, pero muestra datos distintos según la URL. Los segmentos que empiezan por dos puntos, como :slug, capturan esa parte variable, y el hook useParams permite leerla:
// src/datos/productos.js
export const productos = [
{ slug: "merino-azul", nombre: "Merino azul océano", precio: 8.9 },
{ slug: "alpaca-crema", nombre: "Alpaca crema", precio: 12.5 },
{ slug: "algodon-coral", nombre: "Algodón coral", precio: 6.4 },
];
// src/paginas/DetalleProducto.jsx
import { useParams, Link } from "react-router";
import { productos } from "../datos/productos.js";
export default function DetalleProducto() {
const { slug } = useParams();
const producto = productos.find((p) => p.slug === slug);
if (!producto) {
return (
<p>
No encontramos ese ovillo. <Link to="/tienda">Ver todos</Link>
</p>
);
}
return (
<article>
<h1>{producto.nombre}</h1>
<p>{producto.precio.toFixed(2)} € por ovillo</p>
<Link to="..">Volver a la tienda</Link>
</article>
);
}
El enlace con to=".." sube un nivel en la jerarquía de rutas, lo que hace el componente independiente de la URL exacta en la que se monte.
La página de la tienda enlaza a cada producto:
// src/paginas/Tienda.jsx
import { Link } from "react-router";
import { productos } from "../datos/productos.js";
export default function Tienda() {
return (
<section>
<h1>Nuestros ovillos</h1>
<ul>
{productos.map((p) => (
<li key={p.slug}>
<Link to={p.slug}>{p.nombre}</Link> ·{" "}
{p.precio.toFixed(2)} €
</li>
))}
</ul>
</section>
);
}
Como el enlace es relativo, to={p.slug} se resuelve como /tienda/merino-azul.
Parámetros de búsqueda con useSearchParams
Además de los segmentos de la ruta, las URLs pueden llevar parámetros de búsqueda después del signo de interrogación, como /tienda?fibra=alpaca&orden=precio. Son ideales para filtros, ordenaciones y paginación, porque el estado queda en la URL y se puede compartir o guardar en favoritos. El hook useSearchParams funciona de forma parecida a useState:
import { useSearchParams } from "react-router";
export default function FiltroTienda() {
const [parametros, setParametros] = useSearchParams();
const orden = parametros.get("orden") ?? "nombre";
return (
<label>
Ordenar por
<select
value={orden}
onChange={(e) => setParametros({ orden: e.target.value })}
>
<option value="nombre">Nombre</option>
<option value="precio">Precio</option>
</select>
</label>
);
}
Al cambiar el selector, la URL se actualiza a /tienda?orden=precio y el componente se vuelve a renderizar con el nuevo valor. Si el usuario recarga la página o comparte el enlace, el filtro se conserva.
Navegación programática con useNavigate
A veces hay que cambiar de página desde el código, no desde un enlace: después de enviar un formulario, tras iniciar sesión o al terminar una compra. Para eso se usa el hook useNavigate, que devuelve una función:
import { useNavigate } from "react-router";
export default function FormularioContacto() {
const navegar = useNavigate();
async function enviar(evento) {
evento.preventDefault();
const datos = new FormData(evento.currentTarget);
await fetch("/api/contacto", { method: "POST", body: datos });
navegar("/gracias", { replace: true });
}
return (
<form onSubmit={enviar}>
<input name="correo" type="email" required />
<textarea name="mensaje" required />
<button type="submit">Enviar</button>
</form>
);
}
La opción replace: true sustituye la entrada actual del historial en lugar de añadir una nueva, de modo que el botón de atrás no devuelve al formulario ya enviado. navegar(-1) equivale a pulsar atrás.
Para redirecciones que dependen del renderizado, como enviar a la portada a quien visita una URL antigua, existe el componente Navigate:
<Route path="catalogo" element={<Navigate to="/tienda" replace />} />
Proteger rutas privadas
Un caso muy habitual es restringir algunas secciones a usuarios autenticados. Con rutas anidadas se puede crear una ruta de protección que compruebe la sesión y, si no existe, redirija al inicio de sesión recordando a dónde quería ir el usuario:
import { Navigate, Outlet, useLocation } from "react-router";
import { useSesion } from "./sesion.js";
function RutaPrivada() {
const { usuario } = useSesion();
const ubicacion = useLocation();
if (!usuario) {
return (
<Navigate
to="/entrar"
replace
state={{ desde: ubicacion.pathname }}
/>
);
}
return <Outlet />;
}
// En App.jsx
<Route element={<RutaPrivada />}>
<Route path="cuenta" element={<Cuenta />} />
<Route path="pedidos" element={<Pedidos />} />
</Route>;
Tras iniciar sesión, la página de acceso puede leer ubicacion.state.desde y navegar de vuelta a esa ruta.
Conviene recordar que esta protección es solo de interfaz. Cualquiera puede modificar el código que se ejecuta en su navegador, por lo que los datos privados deben protegerse siempre en el servidor, comprobando la autenticación en cada petición a la API.
El modo de datos: cargar antes de mostrar
En el modo declarativo, cada componente carga sus propios datos con efectos, lo que suele producir pantallas en blanco o indicadores de carga en cascada. El modo de datos resuelve esto: las rutas se definen como objetos con createBrowserRouter y cada una puede tener una función loader que obtiene los datos antes de renderizar el componente:
// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router";
import Diseno from "./Diseno.jsx";
import Inicio from "./paginas/Inicio.jsx";
import Tienda from "./paginas/Tienda.jsx";
import DetalleProducto from "./paginas/DetalleProducto.jsx";
import ErrorRuta from "./paginas/ErrorRuta.jsx";
const enrutador = createBrowserRouter([
{
path: "/",
Component: Diseno,
ErrorBoundary: ErrorRuta,
children: [
{ index: true, Component: Inicio },
{
path: "tienda",
loader: async () => {
const respuesta = await fetch("/api/productos");
return respuesta.json();
},
Component: Tienda,
},
{
path: "tienda/:slug",
loader: async ({ params }) => {
const respuesta = await fetch(
`/api/productos/${params.slug}`
);
if (respuesta.status === 404) {
throw new Response("Producto no encontrado", {
status: 404,
});
}
return respuesta.json();
},
Component: DetalleProducto,
},
],
},
]);
createRoot(document.getElementById("root")).render(
<StrictMode>
<RouterProvider router={enrutador} />
</StrictMode>
);
En el componente, los datos se leen con el hook useLoaderData:
import { useLoaderData } from "react-router";
export default function Tienda() {
const productos = useLoaderData();
return (
<ul>
{productos.map((p) => (
<li key={p.slug}>{p.nombre}</li>
))}
</ul>
);
}
Si un loader lanza un error, como la respuesta 404 del ejemplo, React Router muestra el ErrorBoundary de la ruta en lugar del componente. Ese componente puede usar el hook useRouteError para saber qué ocurrió y mostrar un mensaje adecuado. El modo de datos también ofrece funciones action para procesar formularios, el componente Form, el hook useNavigation para mostrar indicadores de carga globales y, en las versiones recientes, middleware para ejecutar lógica común, como la autenticación, antes de los loaders.
Dividir el código con carga diferida
En aplicaciones grandes no tiene sentido descargar el código de todas las páginas al entrar en la portada. La carga diferida de las rutas permite que cada página se descargue solo cuando el usuario navega a ella. En el modo de datos, la propiedad lazy importa el módulo de la ruta bajo demanda:
{
path: 'cuenta',
lazy: async () => {
const modulo = await import('./paginas/Cuenta.jsx');
return { Component: modulo.default };
},
}
En el modo declarativo se consigue lo mismo con React.lazy y Suspense:
import { lazy, Suspense } from "react";
const Cuenta = lazy(() => import("./paginas/Cuenta.jsx"));
<Route
path="cuenta"
element={
<Suspense fallback={<p>Cargando…</p>}>
<Cuenta />
</Suspense>
}
/>;
Vite genera automáticamente un archivo JavaScript separado para cada importación dinámica, de modo que el tamaño inicial de la aplicación se reduce.
Desplegar una SPA con React Router
Hay un problema que aparece casi siempre al publicar por primera vez una aplicación con BrowserRouter: la portada funciona, pero al recargar cualquier otra página o abrir un enlace directo, el servidor devuelve un error 404.
La causa es que el servidor busca un archivo real en la ruta solicitada, como /tienda/merino-azul, y no existe: solo hay un index.html. La solución es configurar el servidor para que devuelva index.html en todas las rutas que no correspondan a archivos estáticos. En Nginx:
location / {
try_files $uri $uri/ /index.html;
}
Plataformas como Netlify, Vercel o Cloudflare Pages ofrecen archivos de configuración de reescrituras para el mismo fin. Si no puedes modificar el servidor, por ejemplo en algunos alojamientos estáticos, HashRouter usa URLs con almohadilla, como /#/tienda, que no requieren configuración, aunque son menos limpias y peores para el SEO.
Errores frecuentes
El primero es usar un hook de navegación fuera del enrutador:
Error: useNavigate() may be used only in the context of a <Router> component.
Asegúrate de que el componente está dentro de BrowserRouter o de RouterProvider. Ocurre a menudo en las pruebas, donde hay que envolver el componente en un MemoryRouter.
El segundo es seguir el código de tutoriales antiguos y usar Switch, component o exact, que provocan errores de importación o simplemente no funcionan. Usa Routes y element.
El tercero es usar <a href> en lugar de Link, lo que recarga toda la aplicación en cada clic y pierde el estado.
El cuarto es olvidar el Outlet en un diseño con rutas anidadas: la estructura se muestra, pero el contenido de las rutas hijas no aparece nunca.
El quinto es que el enlace de inicio aparezca siempre activo en un NavLink. Falta la propiedad end.
El sexto es el error 404 al recargar en producción, que se resuelve configurando las reescrituras del servidor como vimos en la sección anterior.
Alternativas a React Router
React Router no es la única opción para enrutar en React. TanStack Router destaca por su tipado completo con TypeScript, incluidos los parámetros de búsqueda, y por su sistema integrado de carga de datos. Wouter es una alternativa minimalista, de muy pocos kilobytes, para aplicaciones sencillas.
Si el proyecto necesita renderizado en el servidor, generación estática o una buena indexación en buscadores, los frameworks completos son una opción más adecuada. Next.js incluye su propio enrutador basado en el sistema de archivos, y el modo framework de React Router, heredero de Remix, ofrece una experiencia equivalente con el mismo modelo de rutas, loaders y actions que hemos visto.
Conclusiones
React Router permite que una aplicación de página única tenga URLs reales, historial de navegación y enlaces compartibles. Su uso básico se reduce a envolver la aplicación en BrowserRouter, declarar las rutas con Routes y Route asociando cada path a un element, y navegar con Link y NavLink en lugar de enlaces HTML normales.
Sobre esa base, las rutas anidadas con Outlet permiten compartir diseños, los segmentos dinámicos y useParams crean páginas de detalle, useSearchParams guarda filtros en la URL y useNavigate y Navigate resuelven la navegación desde el código y las redirecciones. La ruta path="*" actúa como página 404 y las rutas de protección controlan el acceso a las secciones privadas.
El modo de datos, con createBrowserRouter, loaders y límites de error por ruta, mejora la experiencia de carga y simplifica la gestión de datos, y la carga diferida reduce el tamaño inicial de la aplicación. Si migras desde versiones antiguas, recuerda que Switch, component y exact ya no existen, que el paquete principal es ahora react-router y que la versión 8 se distribuye exclusivamente como módulos ES.
Con estos conceptos y una configuración correcta del servidor en producción, tendrás una navegación rápida, accesible y fácil de mantener en cualquier aplicación React.
