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.
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.
- 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. - El resto de rutas estáticas de marketing. Se convierten casi mecánicamente y son donde la ganancia de rendimiento es mayor.
- Rutas dinámicas de solo lectura. Fichas de producto, artículos,
listados. Aquí conviertes
getStaticPropsygetServerSidePropsen componentesasync, que es el grueso del trabajo mecánico. - Rutas autenticadas e interactivas. Paneles, ajustes, checkout. Llevan el riesgo real porque llevan las mutaciones.
- El layout raíz, al final. Mover
_app.tsxy_document.tsxpronto 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 Router | App Router |
|---|---|
getStaticProps | componente async + fetch con revalidate o etiqueta |
getServerSideProps | componente async + cache: 'no-store' |
getStaticPaths | generateStaticParams |
_app.tsx | app/layout.tsx |
_document.tsx | app/layout.tsx (las etiquetas html y body) |
next/head | el export metadata o generateMetadata |
useRouter().query | las props params y searchParams |
| Rutas de API | Route 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.
