De Pages Router a App Router sin congelar nada
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.
Qué comparten los dos routers y qué no
La coexistencia que hace posible una migración incremental no es total, y donde no lo es es donde una migración se atasca.
Comparten la petición. Un despliegue, un dominio, un next.config, y una ruta
resuelve al router que la posee, con app/ ganando cuando ambos definen la
misma ruta. Ese último detalle es el mecanismo sobre el que descansa toda la
estrategia: añades la versión en App Router, gana, y el archivo antiguo se
queda como un rollback que está a un borrado de la limpieza y a un revert de
volver a estar vivo.
No comparten layout. Un pages/_app.tsx envuelve solo rutas de Pages y un
app/layout.tsx solo rutas de App, así que todo lo global - analítica, un
proveedor de tema, una cabecera - existe por duplicado mientras dure la
migración. Duplícalo a propósito y borra una copia al final. Intentar evitar la
duplicación importando uno dentro del otro es como un proveedor de cliente
acaba envolviendo el árbol de servidor.
No comparten navegación en cliente. Un enlace de una ruta de Pages a una de App es una carga completa de documento, no una transición de cliente. Durante una migración larga eso aparece como una página que se siente más lenta que sus dos extremos, y se resuelve sola cuando el par cae del mismo lado.
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.
Dejar la comprobación de autenticación para el final. La autenticación
escrita para getServerSideProps devuelve una redirección desde una función
que posee toda la ruta. En el App Router la decisión equivalente suele
pertenecer a un layout o a la frontera de la propia ruta, y la forma es lo
bastante distinta como para que los equipos lo aplacen, lo que significa que
las rutas detrás son las últimas en moverse y la migración se para a un paso
del final justo en las superficies que más importan. Mueve una ruta autenticada
pronto, a propósito, para fijar el patrón mientras hay poco en juego.
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.
Cuándo parar
Una migración que llega al noventa por ciento y se queda ahí es un resultado común e infravalorado, y no es automáticamente un fracaso. Dos routers en una aplicación son una configuración soportada, no un estado roto, y el diez por ciento restante suele ser un puñado de rutas con una razón genuina para ser incómodas: una dependencia de un servidor propio, una librería sin historia de Server Components, una página que se va a sustituir el trimestre que viene de todos modos.
La prueba de si parar es aceptable es si las rutas de Pages que quedan te cuestan algo de forma continua. Si están aisladas, tienen poco tráfico y nadie las edita, terminar es pulcritud y no valor. Si tienen secuestrado el layout compartido - de modo que el lado App Router no puede quitar el proveedor duplicado -, entonces te están costando en todas las rutas, y ese es el argumento para terminar.
Lo que nunca es aceptable es parar sin dejarlo por escrito. Una migración sin un estado final documentado se convierte, en cuanto cambia una persona del equipo, en una base de código donde nadie sabe qué convención es la vigente y las dos se siguen copiando.
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.
