32blogby StudioMitsu

Por qué SSR no funciona en Next.js App Router (y cómo solucionarlo)

Los datos no se actualizan, las páginas se cachean cuando no deberían — problemas comunes de SSR en App Router explicados con soluciones concretas.

8 min read
Contenido

Mientras construía 32blog.com, me topé con esto varias veces: "Los datos están actualizados — ¿por qué no aparecen?" Redesplegar no ayudó. Limpiar cachés no ayudó.

Casi siempre, la causa era una mala comprensión de la estrategia de renderizado de App Router. El patrón más común: "Pensé que estaba usando SSR, pero Next.js cambió silenciosamente a generación estática."

Este artículo recorre los patrones típicos donde SSR no se comporta como esperas en App Router, con soluciones para cada uno.

Entendiendo las estrategias de renderizado de App Router

Primero, los fundamentos. Las páginas de App Router funcionan bajo una de tres estrategias:

EstrategiaCuándoCaso de uso
Generación estática (SSG)Una vez en tiempo de buildContenido que no cambia
Renderizado dinámico (SSR)En cada solicitudDatos por usuario o en tiempo real
ISRRegeneración periódicaContenido que se actualiza ocasionalmente

El problema es que Next.js optimiza automáticamente a generación estática cuando cree que puede. Las páginas que pretendes que sean dinámicas se vuelven estáticas silenciosamente.

¿Agregar "use client" habilita SSR?

Primero desmintamos un mito: "use client" no tiene nada que ver con SSR.

"use client" marca un componente como Client Component. Define los límites entre Server y Client Component — no significa "no ejecutes esto en el servidor."

tsx
// Concepto erróneo común: "use client" significa solo navegador
"use client";

import { useState, useEffect } from "react";

export function UserProfile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch("/api/user").then(r => r.json()).then(setUser);
  }, []);

  if (!user) return <p>Loading...</p>;
  return <p>{user.name}</p>;
}

Eso es fetching del lado del cliente — nada que ver con SSR. Los datos del servidor no se obtienen. El SEO también se ve afectado.

Para obtener datos realmente con SSR, escribe un Server Component asíncrono:

tsx
// Server Component data fetching (SSR/SSG)
// Sin "use client" = Server Component
export default async function UserProfile() {
  // Se ejecuta en el servidor
  const user = await getUser();

  return <p>{user.name}</p>;
}

¿Por qué Next.js cambia a generación estática?

Next.js optimiza a generación estática cuando todo lo siguiente es verdad:

  1. No se usan APIs dinámicas (cookies(), headers(), etc.)
  2. No se accede a searchParams
  3. No hay opciones de fetch que invaliden el caché
  4. No se establece export const dynamic = "force-dynamic"

Un Server Component simple con data fetching le parece a Next.js "puedo ejecutar esto en tiempo de build y cachearlo" — así que se convierte en SSG automáticamente.

Cómo verificar: lee el log de build

bash
npm run build

El log de build muestra la estrategia de renderizado de cada página:

Route (app)                    Size     First Load JS
┌ ○ /                          4.2 kB        120 kB
├ ○ /[locale]/about            2.1 kB        118 kB
├ λ /[locale]/dashboard        5.3 kB        121 kB
└ ● /[locale]/blog/[slug]      8.4 kB        124 kB
  • — Generación estática (SSG)
  • λ — Renderizado dinámico (SSR)
  • — ISR (estático con revalidación)

Si una página muestra pero esperas que sea dinámica, ahí está tu problema de SSR.

Solución 1: force-dynamic para renderizado dinámico garantizado

La solución más simple. Agrega un export a tu archivo de página.

tsx
// app/[locale]/dashboard/page.tsx

// Agrega esto — eso es todo lo que necesitas para forzar SSR
export const dynamic = "force-dynamic";

export default async function DashboardPage() {
  // Se ejecuta en cada solicitud
  const data = await fetchDashboardData();

  return <Dashboard data={data} />;
}

Ten en cuenta que force-dynamic significa que el procesamiento del servidor se ejecuta en cada solicitud, lo que aumenta la carga. Úsalo solo donde realmente sea necesario.

Solución 2: Deshabilitar explícitamente el caché de fetch

Next.js 15+ usa no-store por defecto, así que esto es menos común ahora. Pero si force-cache termina en tu código, anula el valor predeterminado.

tsx
// Pretendía ser SSR, pero force-cache lo fija en tiempo de build
export default async function PricePage() {
  const prices = await fetch("https://api.example.com/prices", {
    cache: "force-cache", // Cacheado en tiempo de build → nunca se actualiza
  }).then(r => r.json());

  return <PriceTable prices={prices} />;
}
tsx
// Siempre obtener datos frescos
export default async function PricePage() {
  const prices = await fetch("https://api.example.com/prices", {
    cache: "no-store", // Sin caché → se obtiene fresco en cada solicitud
  }).then(r => r.json());

  return <PriceTable prices={prices} />;
}

Establecer no-store en cualquier fetch automáticamente hace que la página contenedora sea dinámica. No necesitas establecer force-dynamic por separado.

Solución 3: Usar APIs dinámicas para activar SSR

Llamar a cookies() o headers() le indica a Next.js que la página debe renderizarse dinámicamente.

tsx
// app/[locale]/profile/page.tsx
import { cookies } from "next/headers";

export default async function ProfilePage() {
  // Solo llamar a cookies() hace que esta página sea dinámica
  const cookieStore = await cookies();
  const sessionToken = cookieStore.get("session")?.value;

  if (!sessionToken) {
    redirect("/login");
  }

  const user = await getUserFromSession(sessionToken);

  return <Profile user={user} />;
}

Este es el patrón estándar para páginas autenticadas. Verificar una cookie de sesión fuerza el renderizado dinámico de forma natural.

Solución 4: Acceder a searchParams hace las páginas dinámicas

Referenciar parámetros de consulta de URL como ?page=2 activa automáticamente el renderizado dinámico.

tsx
// app/[locale]/blog/page.tsx

interface SearchParams {
  page?: string;
  category?: string;
}

export default async function BlogPage({
  searchParams,
}: {
  searchParams: Promise<SearchParams>;
}) {
  // Acceder a searchParams hace que esta página sea automáticamente dinámica
  const { page = "1", category } = await searchParams;
  const posts = await getPosts({ page: Number(page), category });

  return <PostList posts={posts} />;
}

Las páginas con paginación o filtrado naturalmente se vuelven dinámicas porque referencian searchParams.

Solución 5: revalidateTag para invalidación selectiva de caché

Para patrones ISR, usa caché con etiquetas para invalidar entradas específicas cuando el contenido se actualiza.

tsx
// app/[locale]/blog/[slug]/page.tsx
import { unstable_cache } from "next/cache";

const getPost = unstable_cache(
  async (slug: string) => {
    return fetchPostFromCMS(slug);
  },
  ["post"],
  {
    tags: ["posts"],
    revalidate: 3600, // also auto-revalidate every hour
  }
);

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  return <article>{post.content}</article>;
}
tsx
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest } from "next/server";

export async function POST(request: NextRequest) {
  const { secret } = await request.json();

  if (secret !== process.env.REVALIDATE_SECRET) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  revalidateTag("posts");

  return Response.json({ revalidated: true });
}

Cuando un CMS actualiza contenido, llamar a /api/revalidate vía webhook limpia el caché. El siguiente visitante obtiene datos frescos.

generateStaticParams y parámetros dinámicos

Al usar generateStaticParams, controla qué pasa con las rutas no listadas usando dynamicParams.

tsx
// app/[locale]/blog/[slug]/page.tsx
import { notFound } from "next/navigation";

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

// Por defecto: true — los slugs no listados se renderizan dinámicamente en el primer acceso
export const dynamicParams = true;

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) notFound();

  return <article>{post.content}</article>;
}

Con dynamicParams = true (el valor predeterminado), acceder a un slug no listado activa SSR bajo demanda. El resultado se cachea para visitantes posteriores.

Conclusión

Referencia rápida para problemas de SSR en App Router:

SíntomaCausaSolución
Los datos no se actualizanLa página se genera estáticamenteforce-dynamic o cache: "no-store"
Todos ven la misma páginaDatos específicos del usuario obtenidos del lado del clienteUsa cookies() / headers() para activar SSR
El log de build muestra No se usan APIs dinámicasAgrega dynamic = "force-dynamic"
Los datos antiguos persisten después de actualizarforce-cache establecidoUsa revalidate o revalidateTag

Cuando "SSR debería funcionar pero no lo hace," el camino más rápido a la causa raíz es verificar los símbolos ○/λ/● en el log de build. A partir de ahí, la solución se vuelve obvia.