DEV Community

Cover image for Next.js App Router: la guía que me hubiera gustado tener cuando migré de Pages Router
Juan Torchia
Juan Torchia

Posted on • Originally published at juanchi.dev

Next.js App Router: la guía que me hubiera gustado tener cuando migré de Pages Router

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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'] } })
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 />
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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í:

  1. Primero los layouts — reemplazá el _app.tsx y _document.tsx con el layout.tsx raíz
  2. Después las páginas estáticas — las que no tienen data fetching complejo
  3. Luego las páginas con fetch — migrá el data fetching a Server Components
  4. 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)