Migrar desde next/router: Parámetros, URLs poco profundas y errores 404 en App Router
Dejar atrás Next/router: cómo funcionan en su lugar los parámetros, useParams, las actualizaciones superficiales de URL, la navegación programática y las páginas 404 reales en el App Router de Next.js.
Fuentes: Next.js routing: the four next/router habits I had to unlearn
Los desarrolladores provenientes del Pages Router suelen seguir utilizando import { useRouter } from 'next/router'. En un proyecto de App Router, esa importación es engañosa: el módulo sigue estando allí e incluso puede pasar las pruebas de tipo, pero router.query devuelve un valor vacío y el error no es evidente. La navegación en el App Router (el formato predeterminado desde Next.js 13) se encuentra en next/navigation, y varios patrones habituales del Pages Router simplemente ya no existen. Esta guía aborda los cuatro cambios que encontrarás al migrar un pequeño blog al App Router, con código funcional en Next.js 16.3.1 y React 19 en el momento de su redacción.
Los parámetros dinámicos llegan como prop, no desde un hook
En el Pages Router, el enfoque habitual era convertir la página en un componente cliente y leer router.query.id. En el App Router, una página es un componente servidor a menos que se indique lo contrario, y Next.js le pasa una propiedad params. Desde Next.js 15, params es un Promise, por lo que el componente debe ser async y utilizar await para leer cualquier valor.
La página importa notFound para poder interrumpir su ejecución cuando el identificador no corresponda a ninguna entrada:
import { notFound } from 'next/navigation'
El componente a continuación busca la entrada en un mapa en memoria y llama a notFound() cuando los identificadores son desconocidos.
const POSTS: Record<string, { title: string; body: string }> = {
'hello-world': { title: 'Hello World', body: 'The first post.' },
}export default async function BlogPost({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const post = POSTS[id]
if (!post) notFound() return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
)
}
Aquí no hay ningún gancho ni la directiva 'use client', y la búsqueda se realiza en el servidor antes de enviar cualquier HTML. Una solicitud a /blog/hello-world contra la versión de producción devuelve el título y el cuerpo del artículo con un estado 200. Omitir await params es el error más frecuente en las migraciones a Next.js 15 y versiones posteriores: el valor que se desestructura es una promesa, no el ID.
Leer el segmento en el cliente con useParams
El renderizado primero en el servidor no significa que todos los componentes se ejecuten allí. Un formulario de comentarios necesita el ID del artículo en el navegador. Para ello se utiliza useParams de next/navigation en lugar de router.query. El archivo comienza con la directiva:
'use client'
Luego el componente lee ese segmento. Al pasar un argumento de tipo a useParams se obtiene un resultado tipado:
import { useParams } from 'next/navigation'export default function CommentBox() {
const { id } = useParams<{ id: string }>()
return <form data-post={id}>Commenting on {id}</form>
}
A diferencia de la versión del Pages Router, aquí nunca devuelve null: el segmento se resuelve antes de que se renderice cualquier componente del cliente debajo de él. Al renderizarse dentro de la página de publicación, el formulario lleva data-post="hello-world" desde la primera pintura, por lo que no se necesita un estado de carga.
El enrutamiento superficial se convierte en una simple llamada a la History API
El Pages Router permite escribir router.push(url, as, { shallow: true }) para cambiar la URL sin volver a obtener datos. El App Router no tiene una opción shallow. Para actualizar la URL en casos como el orden de clasificación, los filtros o la pestaña activa sin volver a ejecutar los componentes del servidor, debe llamar manualmente a window.history.pushState y leer el valor nuevamente mediante useSearchParams. De nuevo, el archivo es un componente del cliente:
'use client'
El control copia los parámetros actuales, establece sort y agrega la nueva cadena de consulta; newest es la opción de respaldo:
import { useSearchParams } from 'next/navigation'export default function SortControls() {
const searchParams = useSearchParams()
const active = searchParams.get('sort') ?? 'newest' function setSort(sort: string) {
const next = new URLSearchParams(searchParams.toString())
next.set('sort', sort)
window.history.pushState(null, '', `?${next.toString()}`)
} return (
<div>
<button aria-pressed={active === 'newest'} onClick={() => setSort('newest')}>
Newest
</button>
<button aria-pressed={active === 'top'} onClick={() => setSort('top')}>
Top
</button>
</div>
)
}
Next.js sincroniza las llamadas nativas a pushState y replaceState con su enrutador, de modo que useSearchParams detecta el nuevo valor y los botones se actualizan sin necesidad de realizar una solicitud al servidor. Hay dos problemas importantes que conviene conocer:
- Un componente que utilice
useSearchParamsdebe encontrarse dentro de un contenedor<Suspense>. Sin él, la página recurre a la renderización del lado del cliente hasta el contenedor más cercano en lugar de ser renderizada estáticamente, y el resultado de la compilación emitirá una advertencia al respecto. pushStateagrega un registro de historial por cada clic. Si hacer clic en Atrás tras una serie de cambios de orden molesta a los usuarios, utilicereplaceStateen su lugar.
La navegación programática sigue utilizando useRouter desde una nueva ruta
Cuando la navegación debe realizarse a través del router, como al enviar al usuario a la página principal después de enviar un formulario, useRouter sigue siendo la herramienta adecuada. Solo cambia la ruta de importación. También necesita la directiva correspondiente:
'use client'
Y la llamada en sí no cambia:
import { useRouter } from 'next/navigation'export default function GoHome() {
const router = useRouter()
return <button onClick={() => router.push('/')}>Home</button>
}
router.push('/') funciona como antes; el problema real está en la línea de importación.
Las páginas 404 personalizadas son un archivo que envía un estado real de 404
La antigua convención de pages/404.js es reemplazada por un archivo app/not-found.tsx, que se renderiza cada vez que se llama a notFound(), incluyendo desde el componente del servidor mencionado en la sección anterior. La página hace un enlace de regreso a la página principal, por lo que importa Link:
import Link from 'next/link'
El componente en sí es simplemente marcado HTML normal:
export default function NotFound() {
return (
<main>
<h1>404 — no such post</h1>
<Link href="/">Back to the index</Link>
</main>
)
}
El código de estado es importante: un 404 suave, una página que parece un error servida con 200, puede perjudicar el indexado de búsquedas porque los rastreadores la tratan como contenido real. Al solicitar una entrada que no existe, como /blog/nope, se devuelve HTTP 404 junto con el mensaje “no such post”. notFound() establece ese estado, por lo que lo que ven los rastreadores coincide con lo que ven los usuarios.
Deje que la salida de la compilación confirme la migración
Una vez completada la migración, la tabla de rutas impresa por next build es la forma más rápida de verificar el resultado:
Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /blog/[id]
El marcador ƒ indica que /blog/[id] se renderiza dinámicamente porque lee parámetros, mientras que ○ marca las rutas estáticas de índice y aquellas que no se encuentran. Ningún código depende de next/router, router.query ni de shallow: true. Para saber qué se ejecuta dónde, consulte dónde trazar la línea servidor-cliente en una página de App Router.
Puntos clave
- Busque primero en el código fuente
from 'next/router'; cada coincidencia apunta a uno de estos cuatro cambios. - Lea los segmentos dinámicos desde la propiedad
paramsen los componentes del servidor, y desdeuseParamsen los componentes del cliente.
shallow: true por history.pushState o replaceState junto con useSearchParams, todo envuelto en <Suspense>.useRouter para las navegaciones reales, importado desde next/navigation.app/not-found.tsx con notFound() para obtener un estado 404 real en lugar de uno suave.Lecturas relacionadas
- Dónde dibujar la línea servidor-cliente en una página App Router de Next.js — Un modelo mental práctico para los Componentes del Servidor de React: qué se ejecuta dónde, cómo se divide una página de artículo en partes del servidor y del cliente, y las reglas de importación a seguir.
- Construyendo una página de detalle de film resiliente con Next.js App Router — Aprenda cómo obtener y almacenar en caché los datos de la API OMDB correctamente en Next.js App Router utilizando componentes de servidor asíncronos, parámetros esperados y un manejo adecuado de errores 404.
- SEO técnico en el Next.js App Router: Metadatos, mapas de sitio y JSON-LD — Aprenda cómo los ayudantes de metadatos compartidos, las configuraciones predeterminadas del layout raíz, robots.ts, un mapa de sitio dinámico, JSON-LD preciso y las auditorías de página proporcionan a una aplicación Next.js una base sólida para el SEO.
- Una única fuente de verdad para rutas en aplicaciones Next.js — Aprenda cómo un módulo central de rutas tipado reemplaza las rutas en formato cadena dispersas en Next.js, con funciones auxiliares para segmentos dinámicos y exportaciones de rutas agrupadas.
- Dibujar el límite ‘use client’ correctamente en Next.js App Router — Aprenda qué marca realmente la directiva ‘use client’, por qué marcar contenedores hace que los paquetes sean más grandes, y cómo los componentes hoja y las ranuras de hijos mantienen el trabajo del servidor en el servidor.