Saltar al contenido

Migrar de Pages Router a App Router sin congelar el desarrollo

Los dos routers conviven en la misma aplicación, así que la migración va ruta por ruta mientras tu equipo sigue publicando. Este es el orden que funciona y los tres puntos donde suele torcerse.

5 min de lectura

El dato más útil sobre esta migración es que no tienes que elegir. pages/ y app/ corren en la misma aplicación, en el mismo proceso, contra el mismo despliegue. Una petición se resuelve primero contra app/ y cae a pages/ si no encuentra nada.

Eso significa que congelar las features no es un requisito de la migración. Es un síntoma de haberla planificado como un cambio grande en vez de como cuarenta pequeños.

El orden que funciona

Migra desde las hojas hacia dentro, no desde la raíz hacia fuera.

  1. Primero una ruta de poco tráfico y poco riesgo. Algo como /about. No porque importe, sino porque te obliga a construir el layout, el helper de metadatos y las convenciones de datos que usarás en todo lo demás, en una página que nadie notará si se rompe una hora.
  2. El resto de rutas estáticas de marketing. Se convierten casi mecánicamente y son donde la ganancia de rendimiento es mayor.
  3. Rutas dinámicas de solo lectura. Fichas de producto, artículos, listados. Aquí conviertes getStaticProps y getServerSideProps en componentes async, que es el grueso del trabajo mecánico.
  4. Rutas autenticadas e interactivas. Paneles, ajustes, checkout. Llevan el riesgo real porque llevan las mutaciones.
  5. El layout raíz, al final. Mover _app.tsx y _document.tsx pronto mete todas las rutas en el árbol nuevo antes de que ninguna esté lista.

Los equipos que invierten esto -empezando por el armazón porque parece el cimiento- son los que acaban congelando.

En qué se convierte cada API de Pages

Pages RouterApp Router
getStaticPropscomponente async + fetch con revalidate o etiqueta
getServerSidePropscomponente async + cache: 'no-store'
getStaticPathsgenerateStaticParams
_app.tsxapp/layout.tsx
_document.tsxapp/layout.tsx (las etiquetas html y body)
next/headel export metadata o generateMetadata
useRouter().querylas props params y searchParams
Rutas de APIRoute Handlers, o Server Actions para mutaciones

La ruta suele quedar más corta. Esto:

// pages/products/[slug].tsx
export async function getStaticProps({ params }) {
  const product = await getProduct(params.slug);
  if (!product) return { notFound: true };
  return { props: { product }, revalidate: 3600 };
}
 
export async function getStaticPaths() {
  const products = await getProducts();
  return {
    paths: products.map((p) => ({ params: { slug: p.slug } })),
    fallback: 'blocking',
  };
}
 
export default function ProductPage({ product }) {
  return <Product data={product} />;
}

se convierte en esto:

// app/products/[slug]/page.tsx
export async function generateStaticParams() {
  const products = await getProducts();
  return products.map((p) => ({ slug: p.slug }));
}
 
export default async function ProductPage({ params }) {
  const { slug } = await params;
  const product = await getProduct(slug);
  if (!product) notFound();
 
  return <Product data={product} />;
}

Fíjate en await params. En las versiones actuales de Next.js, params y searchParams son promesas. El código copiado de un tutorial de hace dos años las desestructura directamente y falla de una forma que parece un problema de datos.

Los tres puntos donde se tuerce

Marcar el armazón como use client para que la migración compile. Algo del layout viejo usa un proveedor de contexto, el build se queja, y alguien pone 'use client' en la primera línea del layout raíz. La aplicación compila, cada ruta manda el árbol entero al navegador, y el beneficio principal de la migración desaparece mientras el coste se sigue pagando entero. Baja los proveedores a un componente de cliente que envuelva children y deja el layout en el servidor. Es la misma disciplina de frontera que dónde te cuesta de verdad use client.

Portar la capa de datos sin tocarla. getServerSideProps corría una vez por ruta, así que casi todas las bases de código construyeron una función que lo pide todo por página. En el App Router cada componente puede pedir lo suyo, y las peticiones idénticas dentro de un render se deduplican solas. Mantener la función-dios funciona, y también mantiene la ruta entera esperando a la llamada más lenta: nada hace streaming y Suspense no te aporta nada.

Dejar next/head donde estaba. En app/ es silenciosamente inerte. Sin error, sin aviso: solo una ruta sin título y sin descripción, que nadie nota hasta que llega un informe de SEO un mes después. Búscalo con grep antes de publicar y convierte cada caso al export metadata.

Demostrar que funcionó, ruta a ruta

Antes de mover una ruta, registra lo que hace ahora: LCP e INP de campo, el HTML renderizado, el título y el canonical, el JSON-LD, el First Load JS. Después de moverla, compara. Una migración sin un "antes" es una reescritura con pasos de más.

Las rutas que hemos movido así suelen perder entre un 30 % y un 50 % de su JavaScript sin ganar riesgo, porque en ningún momento hubo más de una ruta en estado desconocido.

Y si la base de código que migras es además una que nadie quiere tocar, eso es otro problema encima de este: heredar una base de código Next.js que nadie quiere tocar cubre qué hacer primero.

Volver a todos los artículos