Cambiar tema

Errores comunes de Next.js App Router y cómo solucionarlos: 8 lecciones prácticas para evitar trampas

Easton editorial illustration: route-map drafting table

Cuando empecé con Next.js App Router, las trampas me pegaron fuerte.

A finales del año pasado el proyecto de la empresa se actualizó a Next.js 15. Ya que íbamos a actualizar, aprovechamos para migrar de Pages Router a App Router: la documentación oficial prometía «mejor rendimiento», «mejor experiencia de desarrollo» y una «arquitectura revolucionaria con Server Components». ¿El resultado? El primer día me topé con un montón de problemas raros.

Los datos no se actualizaban, la página no dejaba de girar, la caché no funcionaba como esperaba, Server Component y Client Component eran un lío… Lo peor: un bug me costó 3 horas de depuración hasta descubrir que en error.tsx faltaba 'use client'. En ese momento, no sabía si reír o llorar.

Después hicimos un recuento en el equipo y vimos que el 80 % de los problemas se repetían. Reuní aquí las trampas que pisamos, por si te ahorran unos cuantos rodeos.

Este artículo no va de teoría, sino de experiencia real. En cada problema verás por qué caes en la trampa, cómo la detecté y cómo la resolví. Al terminar, sabrás cómo esquivar las «trampas» típicas de App Router.

Trampas al obtener datos

Trampa 1: volver a pedir datos en el cliente

Escenario:

Necesitaba mostrar información del usuario en la página y, por costumbre, escribí algo así:

// app/profile/page.tsx
'use client'
import { useEffect, useState } from 'react'

export default function ProfilePage() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    fetch('/api/user')
      .then(res => res.json())
      .then(data => setUser(data))
  }, [])

  if (!user) return <div>Cargando...</div>
  return <div>Hola, {user.name}</div>
}

Parece correcto, ¿no? En realidad es un antipatrón clásico: los datos van de la base de datos → API Route → cliente, con un viaje de red de más.

Por qué caes en la trampa:

En la era de Pages Router estábamos acostumbrados a useEffect en el cliente. Con App Router, los Server Components pueden leer datos directamente en el servidor; no hace falta esa capa API intermedia.

La forma correcta:

// app/profile/page.tsx (Server Component por defecto)
import { db } from '@/lib/db'

export default async function ProfilePage() {
  // Consulta directa en el servidor
  const user = await db.user.findFirst()

  return <div>Hola, {user.name}</div>
}

La mejora de rendimiento se nota al instante:

  • Una petición API menos
  • La latencia servidor → base de datos suele ser inferior a 10 ms (cliente → servidor puede superar 100 ms)
  • Menor tamaño del bundle de JavaScript en el cliente

Clave:

Si puedes obtener los datos en un Server Component, no vayas al cliente con fetch. Reserva la obtención en cliente solo cuando haga falta interacción (búsqueda, filtros, actualización en tiempo real).

Trampa 2: caché por defecto en Route Handlers

Escenario:

Escribí una API que devuelve la hora actual y, por mucho que refrescara, la hora no cambiaba:

// app/api/time/route.ts
export async function GET() {
  return Response.json({ time: new Date().toISOString() })
}

Diez refrescos y la misma hora. Dudé si el código se estaba ejecutando.

Por qué caes en la trampa:

Next.js cachea por defecto las peticiones GET en Route Handlers. Eso ayuda con datos estáticos (configuración), pero complica los datos dinámicos.

Solución 1: marcar explícitamente como dinámico

// app/api/time/route.ts
export const dynamic = 'force-dynamic' // Forzar renderizado dinámico

export async function GET() {
  return Response.json({ time: new Date().toISOString() })
}

Solución 2: nuevo comportamiento por defecto en Next.js 15

La buena noticia: en Next.js 15, los GET en Route Handlers no se cachean por defecto. Si sigues en Next.js 14, puedes hacer:

// app/api/time/route.ts
export async function GET() {
  return Response.json(
    { time: new Date().toISOString() },
    { headers: { 'Cache-Control': 'no-store' } }
  )
}

Mi hábito actual:

  • Datos estáticos (config, constantes): marco export const revalidate = 3600
  • Datos dinámicos (usuario, tiempo real): marco export const dynamic = 'force-dynamic'

No confíes en el comportamiento por defecto: deja clara tu intención y el código será más legible.

Trampa 3: olvidar revalidar tras cambiar datos

Escenario:

Hice una app Todo sencilla; al añadir tareas, la lista no se actualizaba:

// app/todos/page.tsx
export default async function TodosPage() {
  const todos = await db.todo.findMany()
  return <TodoList todos={todos} />
}

// app/actions.ts
'use server'
export async function addTodo(text: string) {
  await db.todo.create({ data: { text } })
  // ¡Olvidé revalidar!
}

Tras enviar el formulario seguía viendo datos viejos; solo un refresh manual mostraba la tarea nueva.

Por qué caes en la trampa:

La caché de App Router es agresiva: aunque cambien los datos, la página no se refresca sola. Hay que decirle explícitamente que esa ruta cambió.

La forma correcta:

// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'

export async function addTodo(text: string) {
  await db.todo.create({ data: { text } })
  revalidatePath('/todos') // Revalidar la ruta /todos
}

Truco avanzado:

Si varias páginas muestran la lista (inicio, archivo), revalidateTag es más flexible:

// app/todos/page.tsx
export default async function TodosPage() {
  const todos = await fetch('http://localhost:3000/api/todos', {
    next: { tags: ['todos'] } // Etiquetar los datos
  })
  return <TodoList todos={todos} />
}

// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'

export async function addTodo(text: string) {
  await db.todo.create({ data: { text } })
  revalidateTag('todos') // Revalidar todo lo etiquetado con 'todos'
}

Clave:

Trío tras cambiar datos: escribir → revalidatePath / revalidateTag → redirección (opcional)

Comportamientos confusos de Server y Client Components

Trampa 4: usar Context en un Server Component

Escenario:

Quería un cambio de tema global y escribí un ThemeProvider:

// app/providers.tsx
import { createContext } from 'react'

export const ThemeContext = createContext('light')

export function Providers({ children }) {
  return (
    <ThemeContext.Provider value="dark">
      {children}
    </ThemeContext.Provider>
  )
}

// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Error: You're importing a component that needs createContext. This only works in a Client Component.

Por qué caes en la trampa:

Los Server Components no soportan React Context: se renderizan en el servidor y no tienen gestión de estado en el cliente.

La forma correcta:

El Provider debe ser Client Component y conviene extraerlo:

// app/providers.tsx
'use client' // Clave: marcar como Client Component

import { createContext, useState } from 'react'

export const ThemeContext = createContext('light')

export function Providers({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light')

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

// app/layout.tsx (sigue siendo Server Component)
import { Providers } from './providers'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Trampa que yo mismo pisé:

Al principio puse 'use client' en layout.tsx y toda la app pasó a Client Component: se perdieron las ventajas de Server Components. Recuerda: solo el Provider es Client Component; el Layout permanece Server Component.

Trampa 5: malentender el SSR de Client Components

Escenario:

Usé localStorage en un Client Component; en local funcionaba y en producción fallaba: localStorage is not defined.

// app/components/user-info.tsx
'use client'

export default function UserInfo() {
  const user = JSON.parse(localStorage.getItem('user') || '{}')
  return <div>{user.name}</div>
}

Por qué caes en la trampa:

Muchos creen que 'use client' significa «solo en el cliente», pero los Client Components también se prerenderizan en el servidor (SSR). localStorage solo existe en el navegador; en el servidor falla.

Solución 1: comprobar el entorno

'use client'
import { useEffect, useState } from 'react'

export default function UserInfo() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    // useEffect solo en el cliente
    const userData = JSON.parse(localStorage.getItem('user') || '{}')
    setUser(userData)
  }, [])

  if (!user) return null
  return <div>{user.name}</div>
}

Solución 2: condicional

'use client'

export default function UserInfo() {
  const user = typeof window !== 'undefined'
    ? JSON.parse(localStorage.getItem('user') || '{}')
    : null

  if (!user) return null
  return <div>{user.name}</div>
}

Clave:

Client Component = puede interactuar en el cliente, pero sigue prerenderizándose en el servidor. Código con APIs del navegador (localStorage, window, document) va en useEffect o con comprobación de entorno.

Trampa 6: abusar de ‘use client’

Escenario:

Al empezar con App Router, ante cualquier error añadía 'use client' por reflejo. Al final casi todo el proyecto eran Client Components y las ventajas de Server Components desaparecieron.

Por qué caes en la trampa:

A veces parece que «hace falta Client Component», pero en realidad es un problema de organización del código.

Mal ejemplo:

// app/dashboard/page.tsx
'use client' // ¡No debería!

import { useState } from 'react'

export default function Dashboard() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <Header /> {/* estático */}
      <Stats /> {/* necesita datos del servidor */}
      <Counter count={count} setCount={setCount} /> {/* interactivo */}
    </div>
  )
}

Así toda la página es Client Component y los datos de Stats también van por fetch en el cliente.

Forma correcta:

// app/dashboard/page.tsx (Server Component)
import { db } from '@/lib/db'
import { Counter } from './counter'

export default async function Dashboard() {
  const stats = await db.stats.findFirst() // Datos en el servidor

  return (
    <div>
      <Header /> {/* Server Component */}
      <Stats data={stats} /> {/* Server Component */}
      <Counter /> {/* Client Component */}
    </div>
  )
}

// app/dashboard/counter.tsx
'use client' // Solo este componente es Client Component

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

Mi criterio:

Tres señales de que necesitas 'use client':

  1. Usas hooks de React (useState, useEffect, useContext, etc.)
  2. Escuchas eventos del navegador (onClick, onChange, etc.)
  3. Usas APIs del navegador (localStorage, window, etc.)

Si no cumples ninguna, mantén Server Component.

Trampas de la caché

Trampa 7: confusión con Client Router Cache

Escenario:

El usuario edita un artículo en /posts/1, guarda y vuelve a /posts, pero el título en la lista sigue siendo el antiguo. Solo un refresh completo muestra el cambio.

Por qué caes en la trampa:

App Router tiene Client Router Cache: cachea páginas visitadas; aunque los datos cambien, al navegar puede mostrarse la versión antigua.

Solución 1: refrescar al navegar

// app/posts/[id]/edit/page.tsx
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function updatePost(id: string, title: string) {
  await db.post.update({ where: { id }, data: { title } })

  revalidatePath('/posts') // Revalidar listado
  revalidatePath(`/posts/${id}`) // Revalidar detalle

  redirect('/posts') // Volver al listado
}

Solución 2: router.refresh()

'use client'
import { useRouter } from 'next/navigation'

export function EditForm() {
  const router = useRouter()

  async function handleSubmit() {
    await updatePost(...)
    router.refresh() // Refrescar datos de la ruta actual
    router.push('/posts')
  }
}

Buena noticia en Next.js 15:

En Next.js 15, Client Router Cache no cachea por defecto; el problema casi desaparece. En Next.js 14, conviene gestionarlo a mano.

Trampa 8: revalidate que no surte efecto

Escenario:

Puse revalidate = 60 en un Server Component esperando refresco cada 60 segundos, pero los datos no cambiaban:

// app/news/page.tsx
export const revalidate = 60 // Esperaba regeneración cada 60 s

export default async function NewsPage() {
  const news = await fetch('https://api.example.com/news')
  return <NewsList news={news} />
}

En producción la lista no se actualizó en todo el día.

Por qué caes en la trampa:

revalidate solo aplica en producción; en desarrollo (npm run dev) no hay caché. Además solo funciona en páginas generadas estáticamente; si la página es dinámica, revalidate no sirve.

Pasos de diagnóstico:

  1. Confirmar producción:
npm run build
npm run start
  1. Comprobar si la página es estática:

En el build deberías ver ○ Static o ● SSG. Si aparece λ Dynamic, la página se trata como dinámica.

  1. Causas habituales de renderizado dinámico:
  • Uso de cookies() o headers()
  • Uso de searchParams
  • Route Handler sin revalidate explícito

Solución:

// app/news/page.tsx
export const revalidate = 60

export default async function NewsPage() {
  const news = await fetch('https://api.example.com/news', {
    next: { revalidate: 60 } // revalidate a nivel de fetch
  })

  return <NewsList news={news} />
}

Mi hábito actual:

  • Contenido estático puro: generateStaticParams + revalidate
  • Parámetros dinámicos: ISR (Incremental Static Regeneration)
  • Datos en tiempo real: dynamic = 'force-dynamic', sin depender de revalidate

Trampas en el manejo de errores

Trampa 9: olvidar ‘use client’ en error.tsx

Escenario:

Creé error.tsx para errores de página y obtuve: ReactServerComponentsError: Client Component must be used in a Client Component boundary.

// app/error.tsx (incorrecto)
export default function Error({ error, reset }) {
  return (
    <div>
      <h2>¡Error!</h2>
      <button onClick={reset}>Reintentar</button>
    </div>
  )
}

Por qué caes en la trampa:

error.tsx debe ser Client Component: usa Error Boundary de React, que solo funciona en el cliente.

Forma correcta:

// app/error.tsx
'use client' // Obligatorio

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>¡Error!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Reintentar</button>
    </div>
  )
}

Clave:

Entre error.tsx, loading.tsx y not-found.tsx, solo error.tsx debe ser Client Component; los otros pueden ser Server Component.

Trampa 10: redirect dentro de try/catch

Escenario:

En un Server Action validaba un formulario y, si fallaba, quería redirigir; el redirect quedó dentro del catch y la navegación no funcionó.

// app/actions.ts (incorrecto)
'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
    redirect(`/users/${user.id}`) // ¡Lo captura el catch!
  } catch (error) {
    console.error(error)
    return { error: 'Failed to create user' }
  }
}

Por qué caes en la trampa:

redirect() lanza un error especial que Next.js intercepta para navegar. Si está dentro de try/catch, tu catch lo atrapa y la redirección falla.

Forma correcta:

// app/actions.ts
'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
    // No redirect aquí
    return { success: true, userId: user.id }
  } catch (error) {
    console.error(error)
    return { error: 'Failed to create user' }
  }
}

// Redirect en el llamador
export async function handleSubmit(data: FormData) {
  const result = await createUser(data)
  if (result.success) {
    redirect(`/users/${result.userId}`) // Fuera del try/catch
  }
}

O así:

'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
  } catch (error) {
    console.error(error)
    return { error: 'Failed to create user' }
  }

  redirect(`/users/${user.id}`) // Después del try/catch
}

Trampas al migrar

Trampa 11: 404.js y 500.js ya no sirven

Escenario:

Al migrar desde Pages Router dejé pages/404.js y pages/500.js, pero esas páginas no se mostraban.

Por qué caes en la trampa:

El manejo de errores cambió por completo:

  • 404.jsnot-found.tsx
  • 500.jserror.tsx
  • Error global → global-error.tsx

Forma correcta:

// app/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h2>404 - Página no encontrada</h2>
      <Link href="/">Volver al inicio</Link>
    </div>
  )
}

// app/error.tsx
'use client'

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>500 - Error del servidor</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Reintentar</button>
    </div>
  )
}

// app/global-error.tsx (errores del layout raíz)
'use client'

export default function GlobalError({ error, reset }) {
  return (
    <html>
      <body>
        <h2>Error global</h2>
        <p>{error.message}</p>
        <button onClick={reset}>Reintentar</button>
      </body>
    </html>
  )
}

Trampa 12: next-seo deja de funcionar

Escenario:

El proyecto usaba mucho next-seo para metadatos; tras migrar a App Router dejó de aplicarse.

// pages/blog/[slug].tsx (era Pages Router)
import { NextSeo } from 'next-seo'

export default function BlogPost({ post }) {
  return (
    <>
      <NextSeo
        title={post.title}
        description={post.excerpt}
        openGraph={{
          title: post.title,
          description: post.excerpt,
          images: [{ url: post.coverImage }],
        }}
      />
      <article>{post.content}</article>
    </>
  )
}

Por qué caes en la trampa:

App Router trae generateMetadata nativo; next-seo ya no se recomienda.

Plan de migración:

// app/blog/[slug]/page.tsx
import { Metadata } from 'next'

export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPost(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  }
}

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug)
  return <article>{post.content}</article>
}

Ventajas:

  1. Tipado seguro (TypeScript)
  2. Soporta async/await (consulta directa a la base de datos)
  3. Mejor rendimiento (renderizado en servidor)

Consejos de optimización de rendimiento

Evitar Client Components innecesarios

Problema: toda la página pasa a Client Component y se pierden las ventajas de Server Components.

Solución:

Estrategia de «Client Components en las hojas»:

// ❌ Mal enfoque
// app/dashboard/page.tsx
'use client'
export default function Dashboard() {
  return (
    <div>
      <Header />
      <Sidebar />
      <MainContent />
      <Footer />
    </div>
  )
}

// ✅ Buen enfoque
// app/dashboard/page.tsx (Server Component)
import { Header } from './header'
import { Sidebar } from './sidebar'
import { MainContent } from './main-content'
import { Footer } from './footer'

export default function Dashboard() {
  return (
    <div>
      <Header /> {/* Server Component */}
      <Sidebar /> {/* Client Component (interactivo) */}
      <MainContent /> {/* Server Component */}
      <Footer /> {/* Server Component */}
    </div>
  )
}

// app/dashboard/sidebar.tsx
'use client' // Solo este es Client Component
export function Sidebar() {
  const [collapsed, setCollapsed] = useState(false)
  return <aside>...</aside>
}

Optimizar límites de Suspense

Problema: toda la página espera datos lentos y la primera pantalla tarda en aparecer.

Solución:

Divide la carga con <Suspense>:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { FastComponent } from './fast'
import { SlowComponent } from './slow'

export default function Dashboard() {
  return (
    <div>
      {/* Datos rápidos al instante */}
      <FastComponent />

      {/* Datos lentos con skeleton */}
      <Suspense fallback={<div>Cargando...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  )
}

Obtención de datos en paralelo

Problema: peticiones en serie; el tiempo total es la suma de todas.

Solución:

// ❌ Serie (lento)
export default async function Page() {
  const user = await getUser() // 100ms
  const posts = await getPosts() // 200ms
  const comments = await getComments() // 150ms
  // Total: 450ms
}

// ✅ Paralelo (rápido)
export default async function Page() {
  const [user, posts, comments] = await Promise.all([
    getUser(),
    getPosts(),
    getComments(),
  ])
  // Total: 200ms (el más lento)
}

Trampas del entorno de desarrollo

Trampa 13: fugas de conexión por hot reload

Escenario:

Tras un rato en desarrollo, la base de datos devolvía: too many connections.

Por qué caes en la trampa:

El hot reload vuelve a ejecutar módulos; si creas la conexión a nivel global, cada recarga abre una conexión nueva sin cerrar la anterior.

Solución:

// lib/db.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = global as unknown as { prisma: PrismaClient }

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log: ['query'],
  })

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}

En desarrollo se reutiliza la misma instancia de Prisma tras hot reload.

Trampa 14: el servidor de desarrollo se vuelve lento

Escenario:

Tras media hora con npm run dev, el hot reload se volvía muy lento o se colgaba.

Por qué caes en la trampa:

El servidor de desarrollo de App Router consume mucha memoria con muchas páginas, sobre todo con rutas dinámicas.

Solución temporal:

  1. Reiniciar el servidor (parche rápido)
  2. Reducir el file watching innecesario:
// next.config.js
module.exports = {
  webpack: (config) => {
    config.watchOptions = {
      poll: 1000, // Comprobar cambios cada segundo (menos frecuente)
      aggregateTimeout: 300,
      ignored: /node_modules/,
    }
    return config
  },
}

Solución a largo plazo:

Actualizar a Next.js 15 y usar Turbopack:

npm run dev --turbo

Turbopack acelera el hot reload más de 10 veces; proyectos grandes dejan de sentirse pesados.

Resumen: lista anti-trampas

Tras tantos casos, armé una checklist rápida. Repásala al iniciar un proyecto nuevo y evitarás el 90 % de los problemas:

Obtención de datos

  • ☑ Si puedes, obtén datos en Server Component; evita Client Component + useEffect
  • ☑ En Route Handlers marca dynamic = 'force-dynamic' o revalidate
  • ☑ Tras cambiar datos, usa revalidatePath / revalidateTag

Server / Client Components

  • ☑ El Provider debe ser Client Component; el Layout sigue siendo Server Component
  • ☑ APIs del navegador (localStorage, window) van en useEffect o con comprobación de entorno
  • ☑ Pon 'use client' solo donde haga falta interactividad; no conviertas toda la página «por comodidad»

Manejo de errores

  • error.tsx debe llevar 'use client'
  • ☑ No pongas redirect dentro de try/catch
  • ☑ Errores del layout raíz: global-error.tsx, no error.tsx

Caché

  • ☑ Actualiza a Next.js 15 para defaults de caché más razonables
  • revalidate solo en producción; no lo uses en desarrollo
  • ☑ En páginas dinámicas usa dynamic = 'force-dynamic', no revalidate

Migración

  • 404.jsnot-found.tsx, 500.jserror.tsx
  • next-seogenerateMetadata
  • getServerSideProps → fetch directo en Server Component
  • useRouter de next/routernext/navigation

Rendimiento

  • ☑ Suspense para separar componentes rápidos y lentos
  • ☑ Datos en paralelo (Promise.all)
  • ☑ Conexión a base de datos en singleton en desarrollo
  • ☑ Turbopack (npm run dev --turbo)

Palabras finales

App Router tiene curva de aprendizaje; al principio es normal tropezar. Cuando internalizas estos patrones, la productividad sube de verdad.

Mis hábitos hoy:

  1. Antes de una función nueva, pienso el flujo de datos: ¿hace falta render en servidor o interacción en cliente?
  2. Ante un problema, miro la salida del build: ¿Static o Dynamic? ¿Por qué?
  3. Uso DevTools: Network para contar peticiones, Console para el stack de errores
  4. No confío en defaults: dejo claro caché, modo de render y revalidación

Lo más importante: no dejes que estas trampas te asusten; prueba en código y cada una se queda grabada. La documentación oficial de Next.js App Router es muy completa; casi siempre la respuesta está ahí.

Si este artículo te ayudó, compártelo con quien esté en mitad del lío. Si encuentras trampas nuevas, déjalas en comentarios y seguiré ampliando la lista.

¡Que en App Router tomes atajos buenos y escribas código elegante (y no más bugs)!

FAQ

¿Cómo distinguir Server Component de Client Component?
Server Component (por defecto):
• Se ejecuta en el servidor y no se envía al cliente
• No puede usar useState, useEffect ni otros hooks
• No puede usar APIs del navegador

Client Component (requiere marcado):
• Se marca con la directiva 'use client'
• Puede usar todos los hooks de React
• Puede usar APIs del navegador

Regla práctica: si necesitas interactividad o APIs del navegador, usa Client Component
¿Por qué no se actualizan los datos?
fetch de Next.js usa caché por defecto.

Soluciones:
• Configurar cache: 'no-store' (obtener datos nuevos en cada petición)
• Usar next: { revalidate: 60 } (revalidar tras 60 segundos)
• En Client Component, usar router.refresh() para forzar la actualización

Comprobación: revisa la salida del build y confirma si la página es Dynamic o Static
¿Qué hacer si la página sigue cargando?
Posibles causas:
• async Server Component sin manejar bien el estado de carga
• Límite de Suspense mal configurado
• Fallo al obtener datos sin manejo de errores

Soluciones:
• Añadir archivo loading.tsx
• Envolver componentes async con Suspense
• Añadir error.tsx para manejar errores
¿Por qué error.tsx no funciona?
error.tsx debe ser Client Component.

Hay que añadir la directiva 'use client':
'use client'

export default function Error({ error, reset }) {
return <div>Error: {error.message}</div>
}

Nota: error.tsx solo captura errores de componentes hijos, no los suyos propios
¿Cómo migrar de Pages Router a App Router?
Cambios principales:
• getServerSideProps → async Server Component
• getStaticProps → generación estática (por defecto)
• next/router → next/navigation
• _app.js → layout.tsx
• _document.js → ya no hace falta (lo cubre layout.tsx)

Recomendación: prueba primero con 1-2 páginas piloto y, cuando el flujo funcione, extiende el resto
¿Cómo entender el mecanismo de caché?
Capas de caché en Next.js:
• Request Memoization: en una misma petición, fetch idéntico solo se ejecuta una vez
• Data Cache: las respuestas de fetch se almacenan en caché
• Full Route Cache: toda la página se cachea (generación estática)
• Router Cache: caché de rutas en el cliente

Control: usa cache: 'no-store', next: { revalidate }, etc.
¿Cómo depurar problemas de App Router?
Métodos de depuración:
• Revisar la salida del build (npm run build) para confirmar el tipo de página
• Usar el panel Network de DevTools
• Revisar errores en Console
• Consultar la salida del terminal de Next.js

Problemas frecuentes:
• Página Static cuando debería ser Dynamic → revisa la config de caché de fetch
• Datos que no se actualizan → revisa caché y revalidate
• Página que no deja de cargar → revisa loading.tsx y Suspense

17 min de lectura · Publicado el: 25 dic 2025 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog