32blogby StudioMitsu

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.

9 min read
Contenido

Cuando construí 32blog.com con el App Router de Next.js 16, el mayor cambio mental fue replantear SSR (Server-Side Rendering) desde cero.

Estaba acostumbrado a getServerSideProps y getStaticProps de Pages Router. Cuando App Router introdujo los Server Components, pasé mucho tiempo averiguando "¿cómo uso esto realmente?" Este artículo responde todas esas preguntas 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é.

¿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.

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={`/en/${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 — el caché funciona de manera diferente.

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"];
  const categories = ["nextjs", "react", "claude-code", "ffmpeg", "gaming"];

  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;
}

¿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."

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" }];
}

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">Send</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+) y useFormStatus.

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.