Inicio / Artículos

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.

Editor: REACTAPP.TOP6 min de lectura1340 palabras

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 useSearchParams debe 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.
  • pushState agrega un registro de historial por cada clic. Si hacer clic en Atrás tras una serie de cambios de orden molesta a los usuarios, utilice replaceState en 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 params en los componentes del servidor, y desde useParams en los componentes del cliente.
  • Sustituya shallow: true por history.pushState o replaceState junto con useSearchParams, todo envuelto en <Suspense>.
  • Mantenga useRouter para las navegaciones reales, importado desde next/navigation.
  • Utilice app/not-found.tsx con notFound() para obtener un estado 404 real en lugar de uno suave.
  • Lecturas relacionadas