Next.js App Router: la guía que me hubiera gustado tener cuando migré de Pages Router
Era un martes a las 11 de la noche y tenía un cliente en producción con el carrito de compras roto. El problema: había migrado a App Router siguiendo la documentación oficial como si fuera un manual de IKEA — metódicamente, con fe ciega — y me había olvidado de entender por qué funcionaba lo que funcionaba. Cuando algo se rompió, no tenía idea de dónde buscar.
Esta es la guía que me hubiera gustado tener. No la de Vercel. La mía.
El cambio mental que nadie te dice que necesitás
El error más grande que cometí fue tratar App Router como si fuera Pages Router con carpetas distintas. No lo es. Es un paradigma diferente.
En Pages Router, todo componente es un Client Component por defecto. Podés usar useState, useEffect, fetch del lado del servidor con getServerSideProps — pero es todo explícito, separado, prolijo.
En App Router, todo componente es un Server Component por defecto. Esto significa que se ejecuta en el servidor, nunca llega al bundle del cliente, puede hablar directo con la base de datos, y no tiene acceso a window, localStorage, ni hooks de React.
Cuando migré mi primer proyecto, pasé tres horas debuggeando esto:
// app/dashboard/page.tsx
export default function Dashboard() {
const [count, setCount] = useState(0) // 💥 ERROR
// TypeError: useState is not a function
return <div>{count}</div>
}
El fix no es "usar Pages Router". El fix es entender cuándo necesitás interactividad y marcar explícitamente ese componente:
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(c => c + 1)}>
Clicks: {count}
</button>
)
}
La regla que me tatuaría en la mano: Server Components por defecto, Client Components solo cuando necesitás interactividad, estado del browser, o eventos del DOM.
La estructura de carpetas que sí funciona
El App Router vive en /app. Cada carpeta con un page.tsx se convierte en una ruta. Pero hay archivos especiales que cambian todo:
app/
├── layout.tsx ← Layout raíz (obligatorio)
├── page.tsx ← Ruta /
├── loading.tsx ← UI mientras carga (streaming)
├── error.tsx ← Manejo de errores
├── not-found.tsx ← 404
├── dashboard/
│ ├── layout.tsx ← Layout anidado
│ ├── page.tsx ← Ruta /dashboard
│ └── settings/
│ └── page.tsx ← Ruta /dashboard/settings
└── api/
└── webhook/
└── route.ts ← API Route
Los layouts anidados son lo más poderoso y lo que más confunde. El layout.tsx de una carpeta envuelve a todos sus hijos sin remontarse cuando navegás entre subrutas. Esto es exactamente lo que siempre quisimos y nunca tuvimos limpio en Pages Router.
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex">
<Sidebar /> {/* Se renderiza UNA vez, no se destruye al navegar */}
<main className="flex-1">{children}</main>
</div>
)
}
Fetch de datos: la parte donde casi todos la cagan
Olvidate de getServerSideProps, getStaticProps y getInitialProps. En App Router, fetcheás datos directamente en el componente:
// app/products/page.tsx
async function getProducts() {
const res = await fetch('https://api.tudominio.com/products', {
next: { revalidate: 60 } // Revalida cada 60 segundos
})
if (!res.ok) throw new Error('No se pudo cargar products')
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts() // async/await directo en el componente
return (
<ul>
{products.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
Sí, el componente es async. Sí, funciona. Sí, me pareció raro al principio también.
El sistema de cache que me hizo perder dos horas
Next.js cachea el fetch por defecto. Esto es un feature, no un bug. Pero si no lo entendés, te volvés loco.
// Cacheado indefinidamente (como getStaticProps)
const data = await fetch('/api/data')
// Sin cache (como getServerSideProps)
const data = await fetch('/api/data', { cache: 'no-store' })
// Revalidación por tiempo
const data = await fetch('/api/data', { next: { revalidate: 3600 } })
// Revalidación por tag (on-demand)
const data = await fetch('/api/data', { next: { tags: ['products'] } })
Ese último es el que me salvó cuando el cliente me preguntaba por qué los precios actualizados no aparecían. Con tags podés invalidar el cache cuando muta un dato:
// app/api/update-product/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const body = await request.json()
await updateProductInDB(body)
revalidateTag('products') // 🔥 Invalida todo lo que use este tag
return Response.json({ ok: true })
}
Streaming y Suspense: la magia que justifica todo el dolor
Esto es lo que más me enamoró del App Router y lo que hacía imposible Pages Router.
El streaming te permite enviar partes de la página al browser mientras otras partes todavía se están computando en el servidor. El usuario ve contenido rápido en lugar de una pantalla en blanco.
Combinado con Suspense, es una locura:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserStats } from './UserStats' // Query rápida
import { SalesChart } from './SalesChart' // Query lenta (agrega datos)
import { RecentOrders } from './RecentOrders'
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Se renderiza casi instantáneo */}
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
{/* Llega cuando termina, sin bloquear lo demás */}
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</div>
)
}
Cada Suspense boundary se resuelve independientemente. Si SalesChart tarda 2 segundos y UserStats tarda 200ms, el usuario ve las stats antes y el chart aparece solo después. Sin JavaScript del cliente. Sin useEffect. Sin estado de loading manual.
El archivo loading.tsx hace exactamente esto a nivel de ruta:
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />
}
Lo que rompe en producción (mi lista de traumas)
1. Los cookies y headers en Server Components
Si necesitás leer una cookie en un Server Component, no usés document.cookie. Usá las funciones de Next.js:
import { cookies, headers } from 'next/headers'
export default async function Page() {
const cookieStore = await cookies()
const token = cookieStore.get('auth-token')
const headersList = await headers()
const userAgent = headersList.get('user-agent')
return <div>Token: {token?.value}</div>
}
2. Pasar funciones como props a Client Components
Esto me rompió la cabeza al principio:
// ❌ NO podés pasar una función de un Server Component a un Client Component
export default function ServerComponent() {
const handleClick = () => console.log('click')
return <ClientButton onClick={handleClick} /> // Error en runtime
}
// ✅ La función tiene que vivir en el Client Component
'use client'
export function ClientButton() {
const handleClick = () => console.log('click')
return <button onClick={handleClick}>Click</button>
}
La razón es simple: una función de JavaScript no se puede serializar para enviarla entre servidor y cliente. Tiene sentido cuando lo pensás, pero duele cuando lo descubrís a las 2am.
3. El router de navegación cambió
Olvidate de useRouter de next/router. Ahora es next/navigation:
'use client'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
export function NavComponent() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
return (
<button onClick={() => router.push('/dashboard')}>
Ir al dashboard
</button>
)
}
Importar de next/router en App Router simplemente no funciona. No te da error claro, solo se rompe en formas misteriosas.
4. Variables de entorno y el servidor
En Pages Router, tenías NEXT_PUBLIC_ para el cliente y sin prefijo para el servidor. Sigue siendo así, pero con un detalle: en Server Components podés usar variables de servidor directamente. En Client Components, solo las NEXT_PUBLIC_.
// Server Component - OK
const secret = process.env.DATABASE_URL // ✅
// Client Component - NUNCA hagas esto
const secret = process.env.DATABASE_URL // undefined, y si no fuera undefined, sería una filtración de seguridad brutal
La migración incremental que recomiendo
No migrés todo de una. Next.js te deja tener /pages y /app coexistiendo. La estrategia que funcionó para mí:
-
Primero los layouts — reemplazá el
_app.tsxy_document.tsxcon ellayout.tsxraíz - Después las páginas estáticas — las que no tienen data fetching complejo
- Luego las páginas con fetch — migrá el data fetching a Server Components
- Al final la autenticación — es lo más complicado, dejalo para cuando entendés bien el modelo
Cada paso lo podés deployar. Cada paso lo podés testear. No intentés hacer todo de una noche para los lunes.
¿Vale la pena?
Sí. Rotundamente.
Mis métricas antes y después en el proyecto de e-commerce: el Time to First Byte bajó de 340ms a 89ms. El Largest Contentful Paint de 3.2s a 1.1s. El bundle de JavaScript del cliente se redujo un 60% porque la mayoría de los componentes de listado ahora son Server Components.
El cliente ni sabe lo que es App Router, pero me escribió para decirme que la tienda "se siente más rápida". Eso vale todas las noches de debugging.
La curva de aprendizaje es real y duele. Pero una vez que internalizás el modelo mental — servidor por defecto, cliente por excepción, datos cerca de donde se consumen — escribir aplicaciones React se vuelve más limpio que nunca.
Ahora cuando arranco un proyecto nuevo, ya no me imagino volviendo a Pages Router. Es como volver a usar jQuery después de aprender React. Técnicamente funciona. Pero sabés que existe algo mejor.
Este artículo fue publicado originalmente en juanchi.dev
Top comments (0)