32blogby Studio Mitsu

Guía de SSR en Next.js: qué cambió con los Server Components

Cómo funciona SSR en App Router con Server Components. Patrones prácticos para data fetching, caché y streaming en Next.js 16.

by omitsu13 min read
Contenido

En Next.js App Router, SSR funciona a través de Server Components — componentes async que se ejecutan en el servidor por defecto. Ya no necesitas getServerSideProps. Escribes data fetching directamente dentro de los componentes, controlas el caché con opciones de fetch o la directiva use cache, y transmites HTML progresivamente con Suspense.

Cuando construí 32blog.com con el App Router de Next.js 16, el mayor cambio mental fue replantear SSR desde cero. Estaba acostumbrado a getServerSideProps y getStaticProps — cuando llegaron los Server Components, entender los nuevos patrones llevó su tiempo. Este artículo cubre todo en un solo lugar.

Al final, sabrás cómo decidir entre SSR y SSG en App Router, dónde poner tu data fetching y cómo controlar el comportamiento del caché. Si estás depurando problemas de SSR específicos, consulta también la guía de solución de problemas.

¿Cómo cambió SSR con App Router?

En Pages Router, SSR significaba usar getServerSideProps — una función especial que ejecuta procesamiento del lado del servidor en cada solicitud.

tsx
// Pages Router (forma antigua)
// pages/posts/[id].tsx
export async function getServerSideProps(context) {
  const { id } = context.params;
  const post = await fetchPost(id);

  return {
    props: { post },
  };
}

export default function PostPage({ post }) {
  return <article>{post.title}</article>;
}

En App Router, este patrón cambió fundamentalmente. Los componentes mismos pueden ser async.

tsx
// App Router (actual)
// app/posts/[id]/page.tsx
async function fetchPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`);
  return res.json();
}

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

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

Puedes escribir await directamente dentro del componente. El patrón indirecto de pasar props a través de getServerSideProps desapareció — el data fetching y la UI viven en el mismo archivo.

El cambio central es que los componentes ahora se ejecutan en el servidor por defecto. Los Server Components se ejecutan solo del lado del servidor. Puedes escribir consultas a base de datos o llamadas a API directamente, y ese código nunca se envía al navegador. Para una inmersión más profunda en cómo funciona esta arquitectura, consulta React Server Components Explicados.

Cómo decidir entre Server y Client Components

La regla de decisión es simple:

CaracterísticaServer ComponentClient Component
useState / useEffectNo
APIs del navegador (localStorage, etc.)No
Acceso directo a base de datosNo
API keys (nunca expuestas al cliente)No
Eventos de clic e interaccionesNo
Contenido crítico para SEOParcial

Patrón típico de Server Component

tsx
// app/components/ArticleList.tsx
// Sin "use client" = Server Component

import { getArticles } from "@/lib/content";

export async function ArticleList({ category }: { category: string }) {
  // Esto solo se ejecuta en el servidor
  // Es seguro usar API keys directamente aquí
  const articles = await getArticles(category);

  return (
    <ul>
      {articles.map((article) => (
        <li key={article.slug}>
          <a href={`/es/${category}/${article.slug}`}>{article.title}</a>
        </li>
      ))}
    </ul>
  );
}

Mantén los Client Components en el nivel hoja

tsx
// app/components/LikeButton.tsx
"use client";

import { useState } from "react";

// Solo la unidad interactiva mínima se convierte en Client Component
export function LikeButton({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
  const [liked, setLiked] = useState(false);

  const handleLike = async () => {
    if (liked) return;
    setCount((c) => c + 1);
    setLiked(true);
    await fetch("/api/likes", { method: "POST" });
  };

  return (
    <button onClick={handleLike} aria-pressed={liked}>
      {liked ? "❤️" : "🤍"} {count}
    </button>
  );
}
tsx
// app/posts/[id]/page.tsx (Server Component)
import { LikeButton } from "@/components/LikeButton";

export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const post = await fetchPost(id);
  const likeCount = await getLikeCount(id);

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      {/* Incrustar Client Component dentro de Server Component */}
      <LikeButton initialCount={likeCount} />
    </article>
  );
}

Cómo controlar el caché de fetch

En App Router, fetch se cacheaba por defecto (antes de Next.js 15). Desde Next.js 15, el valor predeterminado cambió a sin caché (no-store). Como 32blog.com funciona con Next.js 16, el predeterminado es sin caché.

tsx
// Next.js 15+ — fetch usa no-store por defecto
const res = await fetch("https://api.example.com/posts");

// Optar explícitamente por SSG (fetch una vez en tiempo de build)
const res = await fetch("https://api.example.com/posts", {
  cache: "force-cache",
});

// Regenerar a intervalos fijos (equivalente a ISR)
const res = await fetch("https://api.example.com/posts", {
  next: { revalidate: 3600 }, // recheck every hour
});

// Siempre fresco (equivalente a SSR)
const res = await fetch("https://api.example.com/posts", {
  cache: "no-store",
});

Estas son opciones en la función fetch. Para data fetching que no usa fetch — como consultas directas a base de datos con Prisma — necesitas la directiva use cache. Se convirtió en estable en Next.js 16.

tsx
import { cacheLife, cacheTag } from "next/cache";

async function getRecentPosts(category: string) {
  "use cache";
  cacheLife("hours"); // caché por 1 hora
  cacheTag("posts");

  // Consulta directa a BD — sin fetch involucrado
  return await db.post.findMany({
    where: { category },
    orderBy: { createdAt: "desc" },
    take: 10,
  });
}

La antigua API unstable_cache aún funciona pero se considera legacy — use cache es el reemplazo recomendado. Puedes invalidar datos cacheados desde un Server Action usando revalidateTag().

Configurar caché por segmento de ruta

La configuración del segmento de ruta te permite establecer el comportamiento del caché para un archivo de página completo.

tsx
// app/posts/[id]/page.tsx — Forzar generación estática (equivalente a SSG)
export const dynamic = "force-static";
tsx
// app/posts/[id]/page.tsx — Forzar renderizado dinámico (equivalente a SSR)
export const dynamic = "force-dynamic";
tsx
// Equivalente a ISR: regenerar cada 60 segundos
export const revalidate = 60;

32blog.com usa generateStaticParams para generar estáticamente todas las páginas de artículos en tiempo de build.

tsx
// app/[locale]/[category]/[slug]/page.tsx

// Generar todas las rutas de artículos en tiempo de build
export async function generateStaticParams() {
  const locales = ["ja", "en", "es"];
  const categories = [
    "nextjs", "react", "vercel", "security",
    "claude-code", "ffmpeg", "cli", "gaming",
    "yocto", "renpy", "business",
  ];

  const paths = [];
  for (const locale of locales) {
    for (const category of categories) {
      const articles = await getArticlesByCategory(category, locale);
      for (const article of articles) {
        paths.push({
          locale,
          category,
          slug: article.slug,
        });
      }
    }
  }
  return paths;
}

Para un recorrido completo de técnicas de optimización de build incluyendo generateStaticParams, consulta Optimización de Build en Next.js.

¿Qué cambia Suspense + Streaming?

El SSR de Pages Router funcionaba enviando el HTML completo solo después de que todo terminara de renderizarse. App Router soporta streaming. Combinado con Suspense, puedes enviar HTML progresivamente a medida que cada parte está lista.

tsx
// app/posts/[id]/page.tsx
import { Suspense } from "react";
import { PostContent } from "@/components/PostContent";
import { RelatedPosts } from "@/components/RelatedPosts";
import { CommentSection } from "@/components/CommentSection";

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

  return (
    <main>
      {/* El contenido del post aparece primero (API rápida) */}
      <Suspense fallback={<PostContentSkeleton />}>
        <PostContent id={id} />
      </Suspense>

      {/* Los posts relacionados llegan después (API separada, más lenta) */}
      <Suspense fallback={<RelatedPostsSkeleton />}>
        <RelatedPosts id={id} />
      </Suspense>

      {/* Los comentarios cargan al final (operación más pesada) */}
      <Suspense fallback={<CommentsSkeleton />}>
        <CommentSection id={id} />
      </Suspense>
    </main>
  );
}

El navegador recibe el primer fragmento de HTML tan pronto como PostContent está listo. Los posts relacionados y los comentarios se transmiten después. Los usuarios no esperan a que todo cargue antes de ver el contenido principal.

El beneficio práctico es mejorar el LCP (Largest Contentful Paint). Entregar el contenido más importante — el artículo mismo — a los usuarios más rápido reduce el tiempo que perciben como "nada está cargando." Si encuentras errores de hidratación al mezclar contenido transmitido por el servidor y renderizado en el cliente, la guía de errores de hidratación cubre las causas comunes.

Cuándo usar SSG vs SSR

Aquí tienes una guía rápida de decisión:

CasoRecomendaciónRazón
Posts de blog (contenido estático)SSG (generación estática)Rápido, cacheable en CDN
Listados de productos (inventario cambiante)ISR (revalidate)El caché coincide con la frecuencia de actualización
Páginas de perfil de usuarioSSR (dinámico)Diferente por usuario
Dashboards en tiempo realCSR (Client Component)No se necesita renderizado del servidor

32blog.com usa SSG para páginas de artículos y la página de inicio (lista de artículos generada en tiempo de build). La búsqueda está implementada como Client Component.

tsx
// app/[locale]/page.tsx
// Página de inicio — generada estáticamente

export async function generateStaticParams() {
  return [{ locale: "ja" }, { locale: "en" }, { locale: "es" }];
}

export default async function HomePage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  // Leer archivos MDX en tiempo de build para generar la lista de artículos
  const latestPosts = await getLatestPosts(locale, 6);
  const categories = await getCategories(locale);

  return (
    <main>
      <HeroSection />
      <ArticleGrid posts={latestPosts} />
      <CategoryList categories={categories} />
    </main>
  );
}

Cómo manejar el procesamiento de formularios con Server Actions

App Router introduce Server Actions — procesamiento del lado del servidor que puedes ejecutar directamente desde envíos de formularios sin crear una API route.

tsx
// app/contact/page.tsx
export default function ContactPage() {
  async function submitContact(formData: FormData) {
    "use server"; // Esta función se ejecuta en el servidor

    const name = formData.get("name") as string;
    const email = formData.get("email") as string;
    const message = formData.get("message") as string;

    // Procesamiento del lado del servidor (enviar email, guardar en BD, etc.)
    await sendEmail({ name, email, message });
  }

  return (
    <form action={submitContact}>
      <input name="name" type="text" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Enviar</button>
    </form>
  );
}

El mayor beneficio es mantener las API keys y las conexiones a base de datos fuera del cliente. Para retroalimentación de validación en tiempo real, combínalo con useActionState (React 19+ — renombrado del obsoleto useFormState) y useFormStatus.

FAQ

¿Todavía necesito getServerSideProps en App Router?

No. En App Router, getServerSideProps no existe. Los Server Components son async por defecto, así que escribes data fetching directamente dentro del componente. Para forzar renderizado dinámico (por solicitud), configura export const dynamic = "force-dynamic" o usa headers() / cookies() que automáticamente optan la ruta al renderizado dinámico.

¿Puedo usar useState o useEffect en un Server Component?

No. Los hooks de React como useState, useEffect y useRef solo funcionan en Client Components (archivos con "use client" al inicio). Si necesitas interactividad, extrae esa pieza en un Client Component separado e impórtalo desde el Server Component. Consulta Minimizar "use client" para patrones prácticos.

¿Cómo cacheo consultas a base de datos que no usan fetch?

Usa la directiva use cache (estable en Next.js 16). Agrega "use cache" al inicio de tu función async, luego controla la duración con cacheLife() y la invalidación con cacheTag(). La antigua API unstable_cache aún funciona pero ya no se recomienda.

¿Cuál es la diferencia entre force-dynamic y cache: "no-store"?

force-dynamic es una configuración de segmento de ruta que afecta a toda la página — fuerza renderizado dinámico y establece todos los fetch a no-store. Establecer cache: "no-store" en un fetch individual solo afecta esa solicitud específica; el resto de la página puede generarse estáticamente si no hay otras señales dinámicas.

¿"use client" significa que el componente solo se ejecuta en el navegador?

No. Un componente con "use client" aún se renderiza en el servidor (HTML) en la solicitud inicial — no es invisible para los motores de búsqueda. La diferencia es que su JavaScript se incluye en el bundle del navegador para la hidratación, por lo que los hooks y event handlers funcionan después de que la página carga. Consulta la documentación de renderizado del cliente de Next.js para más detalles.

¿Cómo afecta el streaming al SEO?

Los rastreadores de motores de búsqueda reciben el HTML completamente renderizado — el streaming es transparente para ellos. Googlebot ejecuta JavaScript y espera el contenido, así que los Server Components transmitidos se indexan normalmente. El streaming mejora principalmente el rendimiento percibido por el usuario (LCP) en lugar de cambiar lo que ven los rastreadores.

¿Cuándo debo usar un Server Action vs una API route?

Usa Server Actions para envíos de formularios y mutaciones disparadas desde la UI — se integran naturalmente con el manejo de formularios de React y useActionState. Usa Route Handlers cuando necesites un endpoint API independiente consumido por clientes externos, webhooks o aplicaciones móviles.

Conclusión

El enfoque de SSR de App Router requiere acostumbrarse — especialmente la idea de que "los componentes se ejecutan en el servidor por defecto." Aquí tienes el resumen:

  • Server Component — el predeterminado. Escribe data fetching directamente en el componente. async/await funciona nativamente
  • Client Component — agrega "use client". Los hooks y APIs del navegador funcionan aquí. El código se incluye en el bundle del navegador
  • Caché — Next.js 15+ usa sin caché por defecto. Establece explícitamente force-cache o revalidate cuando sea necesario
  • Streaming — envuelve componentes en Suspense para enviar HTML progresivamente a medida que cada parte está lista
  • SSG vs SSR — decide según la frecuencia de cambio del contenido y si varía por usuario

32blog.com usa este diseño para generar estáticamente todas las páginas en tiempo de build, sirviéndolas desde CDN después del despliegue. El tiempo de respuesta se mantiene constante sin importar cuántos artículos agreguemos — esa es la verdadera fortaleza de la generación estática.