Ir para o conteúdo
·30 min de leitura

React Server Components: Modelos Mentais, Padrões e Armadilhas

O guia prático de React Server Components que eu gostaria que existisse quando comecei. Modelos mentais, padrões reais, o problema de fronteira e os erros que cometi para você não precisar cometer.

Compartilhar:X / TwitterLinkedIn

Passei os três primeiros meses com React Server Components achando que entendia. Tinha lido as RFCs, assistido às palestras de conferências, criado alguns apps de demonstração. Eu estava confiantemente errado sobre quase tudo.

O problema não é que RSC seja complicado. É que o modelo mental é genuinamente diferente de tudo que fizemos no React antes, e todo mundo — inclusive eu — tenta encaixar no molde antigo. "É como SSR." Não é. "É como PHP." Mais perto, mas não. "São só componentes que rodam no servidor." Tecnicamente verdade, praticamente inútil.

O que segue é tudo que eu realmente precisei saber, escrito da forma que eu gostaria que alguém tivesse me explicado. Não a versão teórica. Aquela em que você está encarando um erro de serialização às 11 da noite e precisa entender por quê.

O Modelo Mental Que Realmente Funciona#

Esqueça tudo que você sabe sobre renderização do React por um momento. Aqui está o novo cenário.

No React tradicional (client-side), toda a sua árvore de componentes é enviada ao navegador como JavaScript. O navegador faz o download, analisa, executa e renderiza o resultado. Todo componente — seja um formulário interativo de 200 linhas ou um parágrafo estático de texto — passa pelo mesmo pipeline.

React Server Components dividem isso em dois mundos:

Server Components rodam no servidor. Executam uma vez, produzem seu output e enviam o resultado para o client — não o código. O navegador nunca vê a função do componente, nunca faz download das dependências, nunca re-renderiza.

Client Components funcionam como o React tradicional. São enviados ao navegador, passam por hydration, mantêm estado, lidam com eventos. São o React que você já conhece.

O insight chave que demorei um tempo constrangedor para internalizar: Server Components são o padrão. No Next.js App Router, todo componente é um Server Component a menos que você explicitamente o inclua no client com "use client". Isso é o oposto do que estamos acostumados, e muda como você pensa sobre composição.

O Waterfall de Renderização#

Aqui está o que realmente acontece quando um usuário requisita uma página:

1. A requisição chega ao servidor
2. O servidor executa os Server Components de cima para baixo
3. Quando um Server Component atinge uma fronteira "use client",
   ele para — aquela subárvore vai renderizar no client
4. Server Components produzem o RSC Payload (um formato especial)
5. O RSC Payload é transmitido via streaming para o client
6. O client renderiza os Client Components, costurando-os na
   árvore renderizada pelo servidor
7. A hydration torna os Client Components interativos

O passo 4 é onde mora a maior parte da confusão. O RSC Payload não é HTML. É um formato especial de streaming que descreve a árvore de componentes — o que o servidor renderizou, onde o client precisa assumir e quais props passar pela fronteira.

Ele se parece mais ou menos com isso (simplificado):

M1:{"id":"./src/components/Counter.tsx","chunks":["272:static/chunks/272.js"],"name":"Counter"}
S0:"$Sreact.suspense"
J0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome"}],["$","$L1",null,{"initialCount":0}]]}]

Você não precisa memorizar esse formato. Mas entender que ele existe — que há uma camada de serialização entre servidor e client — vai te poupar horas de debugging. Toda vez que você receber um erro "Props must be serializable", é porque algo que você está passando não consegue sobreviver a essa tradução.

O Que "Roda no Servidor" Realmente Significa#

Quando digo que um Server Component "roda no servidor", quero dizer literalmente. A função do componente executa no Node.js (ou Edge runtime). Isso significa que você pode:

tsx
// app/dashboard/page.tsx — isso é um Server Component por padrão
import { db } from "@/lib/database";
import { headers } from "next/headers";
 
export default async function DashboardPage() {
  const headerList = await headers();
  const userId = headerList.get("x-user-id");
 
  // Query direta ao banco. Sem necessidade de API route.
  const user = await db.user.findUnique({
    where: { id: userId },
  });
 
  const recentOrders = await db.order.findMany({
    where: { userId: user.id },
    orderBy: { createdAt: "desc" },
    take: 10,
  });
 
  return (
    <div>
      <h1>Welcome back, {user.name}</h1>
      <OrderList orders={recentOrders} />
    </div>
  );
}

Sem useEffect. Sem gerenciamento de estado de loading. Sem API route para colar as coisas. O componente é a camada de dados. Essa é a maior vitória do RSC e é a coisa que mais me incomodou no início, porque eu ficava pensando "mas cadê a separação?"

A separação é a fronteira "use client". Tudo acima dela é servidor. Tudo abaixo é client. Essa é a sua arquitetura.

A Fronteira Server/Client#

É aqui que o entendimento da maioria das pessoas quebra, e onde eu passei a maior parte do meu tempo de debugging nos primeiros meses.

A Diretiva "use client"#

A diretiva "use client" no topo de um arquivo marca tudo exportado daquele arquivo como Client Component. É uma anotação no nível do módulo, não no nível do componente.

tsx
// src/components/Counter.tsx
"use client";
 
import { useState } from "react";
 
// Todo esse arquivo agora é "território client"
export function Counter({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}
 
// Isso TAMBÉM é um Client Component porque está no mesmo arquivo
export function ResetButton({ onReset }: { onReset: () => void }) {
  return <button onClick={onReset}>Reset</button>;
}

Erro comum: colocar "use client" em um barrel file (index.ts) que re-exporta tudo. Parabéns, você acabou de tornar toda a sua biblioteca de componentes client-side. Já vi times acidentalmente enviar 200KB de JavaScript dessa forma.

O Que Cruza a Fronteira#

Aqui está a regra que vai te salvar: tudo que cruza a fronteira server-client precisa ser serializável em JSON.

O que é serializável:

  • Strings, numbers, booleans, null, undefined
  • Arrays e objetos simples (contendo valores serializáveis)
  • Dates (serializadas como strings ISO)
  • Server Components (como JSX — vamos chegar nisso)
  • FormData
  • Typed arrays, ArrayBuffer

O que NÃO é serializável:

  • Funções (incluindo event handlers)
  • Classes (instâncias de classes customizadas)
  • Symbols
  • Nós do DOM
  • Streams (na maioria dos contextos)

Isso significa que você não pode fazer isso:

tsx
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList"; // Client Component
 
export default async function Page() {
  const items = await getItems();
 
  return (
    <ItemList
      items={items}
      // ERRO: Funções não são serializáveis
      onItemClick={(id) => console.log(id)}
      // ERRO: Instâncias de classe não são serializáveis
      formatter={new Intl.NumberFormat("en-US")}
    />
  );
}

A solução não é tornar a página um Client Component. A solução é empurrar a interatividade para baixo e o data fetching para cima:

tsx
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
 
export default async function Page() {
  const items = await getItems();
 
  // Passe apenas dados serializáveis
  return <ItemList items={items} locale="en-US" />;
}
tsx
// src/components/ItemList.tsx (Client Component)
"use client";
 
import { useState, useMemo } from "react";
 
interface Item {
  id: string;
  name: string;
  price: number;
}
 
export function ItemList({ items, locale }: { items: Item[]; locale: string }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);
 
  // Cria o formatter no lado do client
  const formatter = useMemo(
    () => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
    [locale]
  );
 
  return (
    <ul>
      {items.map((item) => (
        <li
          key={item.id}
          onClick={() => setSelectedId(item.id)}
          className={selectedId === item.id ? "selected" : ""}
        >
          {item.name} — {formatter.format(item.price)}
        </li>
      ))}
    </ul>
  );
}

O Equívoco da "Ilha"#

Inicialmente eu pensava nos Client Components como "ilhas" — pequenos pedaços interativos em um mar de conteúdo renderizado no servidor. Isso é parcialmente correto mas perde um detalhe crucial: um Client Component pode renderizar Server Components se eles forem passados como children ou props.

Isso significa que a fronteira não é um muro sólido. É mais como uma membrana. Conteúdo renderizado no servidor pode fluir através de Client Components via o padrão de children. Vamos aprofundar isso na seção de composição.

Padrões de Data Fetching#

RSC muda o data fetching fundamentalmente. Nada mais de useEffect + useState + estados de loading para dados conhecidos no momento da renderização. Mas os novos padrões têm suas próprias armadilhas.

Fetch Básico com Cache#

Em um Server Component, você simplesmente faz fetch. O Next.js estende o fetch global para adicionar caching:

tsx
// app/products/page.tsx
export default async function ProductsPage() {
  // Cacheado por padrão — mesma URL retorna resultado em cache
  const res = await fetch("https://api.example.com/products");
  const products = await res.json();
 
  return (
    <div>
      {products.map((product: Product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

Controle o comportamento de caching explicitamente:

tsx
// Revalida a cada 60 segundos (comportamento tipo ISR)
const res = await fetch("https://api.example.com/products", {
  next: { revalidate: 60 },
});
 
// Sem caching — sempre dados frescos
const res = await fetch("https://api.example.com/user/profile", {
  cache: "no-store",
});
 
// Cache com tags para revalidação sob demanda
const res = await fetch("https://api.example.com/products", {
  next: { tags: ["products"] },
});

Depois você pode revalidar por tag a partir de um Server Action:

tsx
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function refreshProducts() {
  revalidateTag("products");
}

Data Fetching Paralelo#

O erro de performance mais comum que eu vejo: data fetching sequencial quando paralelo funcionaria perfeitamente.

Ruim — sequencial (waterfalls):

tsx
// app/dashboard/page.tsx — NÃO FAÇA ISSO
export default async function Dashboard() {
  const user = await getUser();           // 200ms
  const orders = await getOrders();       // 300ms
  const notifications = await getNotifications(); // 150ms
  // Total: 650ms — cada um espera o anterior terminar
 
  return (
    <div>
      <UserInfo user={user} />
      <OrderList orders={orders} />
      <NotificationBell notifications={notifications} />
    </div>
  );
}

Bom — paralelo:

tsx
// app/dashboard/page.tsx — FAÇA ISSO
export default async function Dashboard() {
  // Todos os três disparam simultaneamente
  const [user, orders, notifications] = await Promise.all([
    getUser(),        // 200ms
    getOrders(),      // 300ms (roda em paralelo)
    getNotifications(), // 150ms (roda em paralelo)
  ]);
  // Total: ~300ms — limitado pela requisição mais lenta
 
  return (
    <div>
      <UserInfo user={user} />
      <OrderList orders={orders} />
      <NotificationBell notifications={notifications} />
    </div>
  );
}

Melhor ainda — paralelo com Suspense boundaries independentes:

tsx
// app/dashboard/page.tsx — MELHOR OPÇÃO
import { Suspense } from "react";
 
export default function Dashboard() {
  // Nota: esse componente NÃO é async — ele delega para os filhos
  return (
    <div>
      <Suspense fallback={<UserInfoSkeleton />}>
        <UserInfo />
      </Suspense>
      <Suspense fallback={<OrderListSkeleton />}>
        <OrderList />
      </Suspense>
      <Suspense fallback={<NotificationsSkeleton />}>
        <Notifications />
      </Suspense>
    </div>
  );
}
 
// Cada componente busca seus próprios dados
async function UserInfo() {
  const user = await getUser();
  return <div>{user.name}</div>;
}
 
async function OrderList() {
  const orders = await getOrders();
  return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
 
async function Notifications() {
  const notifications = await getNotifications();
  return <span>({notifications.length})</span>;
}

Esse último padrão é o mais poderoso porque cada seção carrega independentemente. O usuário vê o conteúdo conforme ele fica disponível, não tudo-ou-nada. As seções rápidas não esperam pelas lentas.

Deduplicação de Requisições#

O Next.js automaticamente deduplica chamadas fetch com a mesma URL e opções durante um único passo de renderização. Isso significa que você não precisa elevar o data fetching para evitar requisições redundantes:

tsx
// Ambos os componentes podem buscar a mesma URL
// e o Next.js vai fazer apenas UMA requisição HTTP real
 
async function Header() {
  const user = await fetch("/api/user").then(r => r.json());
  return <nav>Welcome, {user.name}</nav>;
}
 
async function Sidebar() {
  // Mesma URL — automaticamente deduplicada, não é uma segunda requisição
  const user = await fetch("/api/user").then(r => r.json());
  return <aside>Role: {user.role}</aside>;
}

Ressalva importante: isso só funciona com fetch. Se você está usando um ORM ou client de banco de dados diretamente, precisa usar a função cache() do React:

tsx
import { cache } from "react";
import { db } from "@/lib/database";
 
// Envolva sua função de dados com cache()
// Agora múltiplas chamadas no mesmo render = uma única query real
export const getUser = cache(async (userId: string) => {
  return db.user.findUnique({ where: { id: userId } });
});

cache() deduplica durante o tempo de vida de uma única requisição do servidor. Não é um cache persistente — é uma memoização por requisição. Depois que a requisição termina, os valores cacheados são coletados pelo garbage collector.

Padrões de Composição de Componentes#

É aqui que RSC fica genuinamente elegante, uma vez que você entende os padrões. E genuinamente confuso, até entender.

O Padrão "Children como Buraco"#

Esse é o padrão de composição mais importante do RSC e levei semanas para apreciar totalmente. Aqui está o problema: você tem um Client Component que fornece algum layout ou interatividade, e quer renderizar Server Components dentro dele.

Você não pode importar um Server Component em um arquivo de Client Component. No momento em que você adiciona "use client", tudo naquele módulo é client-side. Mas você pode passar Server Components como children:

tsx
// src/components/Sidebar.tsx — Client Component
"use client";
 
import { useState } from "react";
 
export function Sidebar({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(true);
 
  return (
    <aside className={isOpen ? "w-64" : "w-0"}>
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen ? "Close" : "Open"}
      </button>
      {isOpen && (
        <div className="sidebar-content">
          {/* Esses children podem ser Server Components! */}
          {children}
        </div>
      )}
    </aside>
  );
}
tsx
// app/dashboard/layout.tsx — Server Component
import { Sidebar } from "@/components/Sidebar";
import { NavigationLinks } from "@/components/NavigationLinks"; // Server Component
import { UserProfile } from "@/components/UserProfile"; // Server Component
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <Sidebar>
        {/* Esses são Server Components, passados através de um Client Component */}
        <UserProfile />
        <NavigationLinks />
      </Sidebar>
      <main>{children}</main>
    </div>
  );
}

Por que isso funciona? Porque os Server Components (UserProfile, NavigationLinks) são renderizados no servidor primeiro, depois seu output (o RSC payload) é passado como children para o Client Component. O Client Component nunca precisa saber que eram Server Components — ele apenas recebe nós React pré-renderizados.

Pense em children como um "buraco" no Client Component por onde conteúdo renderizado no servidor pode fluir.

Passando Server Components como Props#

O padrão de children se generaliza para qualquer prop que aceite React.ReactNode:

tsx
// src/components/TabLayout.tsx — Client Component
"use client";
 
import { useState } from "react";
 
interface TabLayoutProps {
  tabs: { label: string; content: React.ReactNode }[];
}
 
export function TabLayout({ tabs }: TabLayoutProps) {
  const [activeTab, setActiveTab] = useState(0);
 
  return (
    <div>
      <div className="tab-bar" role="tablist">
        {tabs.map((tab, i) => (
          <button
            key={i}
            role="tab"
            aria-selected={i === activeTab}
            onClick={() => setActiveTab(i)}
            className={i === activeTab ? "active" : ""}
          >
            {tab.label}
          </button>
        ))}
      </div>
      <div role="tabpanel">{tabs[activeTab].content}</div>
    </div>
  );
}
tsx
// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings";   // Server Component — pode buscar dados
import { BillingSettings } from "./BillingSettings";    // Server Component — pode buscar dados
import { SecuritySettings } from "./SecuritySettings";  // Server Component — pode buscar dados
 
export default function SettingsPage() {
  return (
    <TabLayout
      tabs={[
        { label: "Profile", content: <ProfileSettings /> },
        { label: "Billing", content: <BillingSettings /> },
        { label: "Security", content: <SecuritySettings /> },
      ]}
    />
  );
}

Cada componente de configurações pode ser um Server Component async que busca seus próprios dados. O Client Component (TabLayout) apenas lida com a troca de abas. Esse é um padrão incrivelmente poderoso.

Server Components Assíncronos#

Server Components podem ser async. Isso é muito importante porque significa que o data fetching acontece durante a renderização, não como efeito colateral:

tsx
// Isso é válido e bonito
async function BlogPost({ slug }: { slug: string }) {
  const post = await db.post.findUnique({ where: { slug } });
 
  if (!post) return notFound();
 
  const author = await db.user.findUnique({ where: { id: post.authorId } });
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {author.name}</p>
      <div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
    </article>
  );
}

Client Components não podem ser async. Se você tentar tornar um componente "use client" async, o React vai disparar um erro. Isso é uma restrição rígida.

Suspense Boundaries: A Primitiva de Streaming#

Suspense é como você obtém streaming no RSC. Sem Suspense boundaries, a página inteira espera pelo componente async mais lento. Com eles, cada seção faz streaming independentemente:

tsx
// app/page.tsx
import { Suspense } from "react";
import { HeroSection } from "@/components/HeroSection";
import { ProductGrid } from "@/components/ProductGrid";
import { ReviewCarousel } from "@/components/ReviewCarousel";
import { RecommendationEngine } from "@/components/RecommendationEngine";
 
export default function HomePage() {
  return (
    <main>
      {/* Estático — renderiza imediatamente */}
      <HeroSection />
 
      {/* Dados rápidos — aparece rapidamente */}
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid />
      </Suspense>
 
      {/* Velocidade média — aparece quando pronto */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewCarousel />
      </Suspense>
 
      {/* Lento (ML) — aparece por último, não bloqueia o resto */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <RecommendationEngine />
      </Suspense>
    </main>
  );
}

O usuário vê o HeroSection instantaneamente, depois o ProductGrid chega via streaming, depois as reviews, depois as recomendações. Cada Suspense boundary é um ponto de streaming independente.

Aninhar Suspense boundaries também é válido e útil:

tsx
<Suspense fallback={<DashboardSkeleton />}>
  <Dashboard>
    <Suspense fallback={<ChartSkeleton />}>
      <RevenueChart />
    </Suspense>
    <Suspense fallback={<TableSkeleton />}>
      <RecentTransactions />
    </Suspense>
  </Dashboard>
</Suspense>

Se o Dashboard é rápido mas o RevenueChart é lento, o Suspense externo resolve primeiro (mostrando o shell do dashboard), e o Suspense interno do gráfico resolve depois.

Error Boundaries com Suspense#

Combine Suspense com error.tsx para UIs resilientes:

tsx
// app/dashboard/error.tsx — Client Component (obrigatório)
"use client";
 
export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="p-8 text-center">
      <h2>Something went wrong loading the dashboard</h2>
      <p className="text-gray-500">{error.message}</p>
      <button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
        Try again
      </button>
    </div>
  );
}

O arquivo error.tsx automaticamente envolve o segmento de rota correspondente em um React Error Boundary. Se qualquer Server Component naquele segmento lançar um erro, a UI de erro aparece em vez de quebrar a página inteira.

Quando Usar Qual: A Árvore de Decisão#

Depois de construir vários apps em produção com RSC, eu cheguei a um framework de decisão claro. Aqui está o processo mental real que eu passo para cada componente:

Comece com Server Components (o Padrão)#

Todo componente deveria ser um Server Component a menos que haja um motivo específico para não ser. Essa é a regra mais importante.

Torne um Client Component Quando:#

1. Usa APIs exclusivas do navegador

tsx
"use client";
// window, document, navigator, localStorage, etc.
function GeoLocation() {
  const [coords, setCoords] = useState<GeolocationCoordinates | null>(null);
 
  useEffect(() => {
    navigator.geolocation.getCurrentPosition(
      (pos) => setCoords(pos.coords)
    );
  }, []);
 
  return coords ? <p>Lat: {coords.latitude}</p> : <p>Loading location...</p>;
}

2. Usa hooks do React que requerem estado ou efeitos

useState, useEffect, useReducer, useRef (para refs mutáveis), useContext — qualquer um desses requer "use client".

tsx
"use client";
function SearchBar() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);
 
  // Esse componente PRECISA ser um Client Component porque
  // usa useState e gerencia input do usuário
  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

3. Anexa event handlers

onClick, onChange, onSubmit, onMouseEnter — qualquer comportamento interativo significa client-side.

4. Usa bibliotecas client-side

Framer Motion, React Hook Form, Zustand, React Query (para fetching client-side), qualquer biblioteca de gráficos que renderiza em canvas ou SVG interativamente.

Mantenha como Server Component Quando:#

  • Apenas exibe dados (sem interação do usuário)
  • Busca dados de um banco de dados ou API
  • Acessa recursos do backend (file system, variáveis de ambiente com secrets)
  • Importa dependências grandes que o client não precisa (parsers de markdown, syntax highlighters, bibliotecas de data para formatação)
  • Renderiza conteúdo estático ou semi-estático

A Decisão na Prática do Mundo Real#

Aqui está um exemplo concreto. Estou construindo uma página de produto:

ProductPage (Server)
├── ProductBreadcrumbs (Server) — navegação estática, sem interatividade
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│   ├── ProductTitle (Server) — apenas texto
│   ├── ProductPrice (Server) — número formatado, sem interação
│   └── AddToCartButton (Client) — onClick, gerencia estado do carrinho
├── ProductDescription (Server) — markdown renderizado
├── Suspense
│   └── RelatedProducts (Server) — async data fetch, API lenta
└── Suspense
    └── ProductReviews (Server)
        └── ReviewForm (Client) — formulário com validação

Note o padrão: o shell da página e as partes pesadas em dados são Server Components. Ilhas interativas (ImageGallery, AddToCartButton, ReviewForm) são Client Components. Seções lentas (RelatedProducts, ProductReviews) são envolvidas em Suspense.

Isso não é teórico. É assim que minhas árvores de componentes realmente parecem.

Erros Comuns (Já Cometi Todos)#

Erro 1: Tornar Tudo um Client Component#

O caminho de menor resistência ao migrar do Pages Router ou Create React App é colocar "use client" em tudo. Funciona! Nada quebra! Você também está enviando toda a sua árvore de componentes como JavaScript e obtendo zero benefícios do RSC.

Já vi codebases onde o root layout tem "use client". Nesse ponto você está literalmente rodando um app React client-side com passos extras.

A solução: Comece com Server Components. Só adicione "use client" quando o compilador te disser que é necessário (porque você usou um hook ou event handler). Empurre "use client" o mais para baixo na árvore possível.

Erro 2: Prop Drilling Através da Fronteira#

tsx
// RUIM: buscando dados em um Server Component, depois passando através
// de múltiplos Client Components
 
// app/page.tsx (Server)
export default async function Page() {
  const user = await getUser();
  const settings = await getSettings();
  const theme = await getTheme();
 
  return (
    <ClientShell user={user} settings={settings} theme={theme}>
      <ClientContent user={user} settings={settings}>
        <ClientWidget user={user} />
      </ClientContent>
    </ClientShell>
  );
}

Cada pedaço de dado que você passa pela fronteira é serializado no RSC payload. Passa o mesmo objeto cinco vezes? Ele está no payload cinco vezes. Já vi RSC payloads crescerem para megabytes por causa disso.

A solução: Use composição. Passe Server Components como children em vez de passar dados como props:

tsx
// BOM: Server Components buscam seus próprios dados, passam como children
 
// app/page.tsx (Server)
export default function Page() {
  return (
    <ClientShell>
      <UserInfo />      {/* Server Component — busca seus próprios dados */}
      <Settings />      {/* Server Component — busca seus próprios dados */}
      <ClientWidget>
        <UserAvatar />  {/* Server Component — busca seus próprios dados */}
      </ClientWidget>
    </ClientShell>
  );
}

Erro 3: Não Usar Suspense#

Sem Suspense, o Time to First Byte (TTFB) da sua página é limitado pela sua busca de dados mais lenta. Eu tinha uma página de dashboard que levava 4 segundos para carregar porque uma query de analytics era lenta, mesmo que o resto dos dados da página estivesse pronto em 200ms.

tsx
// RUIM: tudo espera por tudo
export default async function Dashboard() {
  const stats = await getStats();         // 200ms
  const chart = await getChartData();     // 300ms
  const analytics = await getAnalytics(); // 4000ms ← bloqueia tudo
 
  return (
    <div>
      <Stats data={stats} />
      <Chart data={chart} />
      <Analytics data={analytics} />
    </div>
  );
}
tsx
// BOM: analytics carrega independentemente
export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <Chart />
      </Suspense>
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics /> {/* Leva 4s mas não bloqueia o resto */}
      </Suspense>
    </div>
  );
}

Erro 4: Erros de Serialização em Runtime#

Esse é particularmente doloroso porque muitas vezes você não pega até produção. Você passa algo não serializável pela fronteira e recebe um erro críptico:

Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.

Culpados comuns:

  • Passar objetos Date (use .toISOString() em vez disso)
  • Passar Map ou Set (converta para arrays/objetos)
  • Passar instâncias de classe de ORMs (use .toJSON() ou espalhe em objetos simples)
  • Passar funções (mova a lógica para o Client Component ou use Server Actions)
  • Passar resultados de modelo Prisma com campos Decimal (converta para number ou string)
tsx
// RUIM
const user = await prisma.user.findUnique({ where: { id } });
// user pode ter campos não serializáveis (Decimal, BigInt, etc.)
return <ClientProfile user={user} />;
 
// BOM
const user = await prisma.user.findUnique({ where: { id } });
const serializedUser = {
  id: user.id,
  name: user.name,
  email: user.email,
  balance: user.balance.toNumber(), // Decimal → number
  createdAt: user.createdAt.toISOString(), // Date → string
};
return <ClientProfile user={serializedUser} />;

Erro 5: Usar Context para Tudo#

useContext só funciona em Client Components. Se você tentar usar um React context em um Server Component, não vai funcionar. Já vi pessoas tornarem o app inteiro um Client Component só para usar um context de tema.

A solução: Para temas e outro estado global, use variáveis CSS definidas no lado do servidor, ou use as funções cookies() / headers():

tsx
// app/layout.tsx (Server Component)
import { cookies } from "next/headers";
 
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  const theme = cookieStore.get("theme")?.value ?? "light";
 
  return (
    <html data-theme={theme} className={theme === "dark" ? "dark" : ""}>
      <body>{children}</body>
    </html>
  );
}

Para estado genuinamente client-side (auth tokens, carrinhos de compra, dados em tempo real), crie um provider Client Component fino no nível apropriado — não no root:

tsx
// src/providers/CartProvider.tsx
"use client";
 
import { createContext, useContext, useState } from "react";
 
const CartContext = createContext<CartContextType | null>(null);
 
export function CartProvider({ children }: { children: React.ReactNode }) {
  const [items, setItems] = useState<CartItem[]>([]);
 
  return (
    <CartContext.Provider value={{ items, setItems }}>
      {children}
    </CartContext.Provider>
  );
}
 
export function useCart() {
  const context = useContext(CartContext);
  if (!context) throw new Error("useCart must be used within CartProvider");
  return context;
}
tsx
// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
 
export default function ShopLayout({ children }: { children: React.ReactNode }) {
  // CartProvider é um Client Component, mas children fluem como conteúdo do servidor
  return <CartProvider>{children}</CartProvider>;
}

Erro 6: Ignorar o Impacto no Tamanho do Bundle#

Uma das maiores vitórias do RSC é que o código de Server Components nunca é enviado ao client. Mas você precisa pensar nisso ativamente. Se você tem um componente que usa um parser de markdown de 50KB e apenas exibe conteúdo renderizado — isso deveria ser um Server Component. O parser fica no servidor, e apenas o output HTML vai para o client.

tsx
// Server Component — marked fica no servidor
import { marked } from "marked"; // Biblioteca de 50KB — nunca é enviada ao client
 
async function BlogContent({ slug }: { slug: string }) {
  const post = await getPost(slug);
  const html = marked(post.markdown);
 
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

Se você tornasse isso um Client Component, marked seria enviada ao navegador. Para nada. O usuário baixaria 50KB de JavaScript só para renderizar conteúdo que poderia ter sido HTML desde o início.

Verifique seu bundle com @next/bundle-analyzer. Os resultados podem te surpreender.

Estratégia de Cache#

O caching no Next.js 15+ foi significativamente simplificado comparado a versões anteriores, mas ainda há camadas distintas para entender.

A Função cache() (React)#

O cache() do React é para deduplicação por requisição, não cache persistente:

tsx
import { cache } from "react";
 
export const getCurrentUser = cache(async () => {
  const session = await getSession();
  if (!session) return null;
 
  return db.user.findUnique({ where: { id: session.userId } });
});
 
// Chame isso em qualquer lugar da sua árvore de componentes durante uma única requisição.
// Apenas uma query real ao banco será executada.

Isso tem escopo de uma única requisição do servidor. Quando a requisição termina, o valor em cache desaparece. É memoização, não caching.

unstable_cache (Next.js)#

Para caching persistente entre requisições, use unstable_cache (o nome é "unstable" há séculos, mas funciona perfeitamente em produção):

tsx
import { unstable_cache } from "next/cache";
 
const getCachedProducts = unstable_cache(
  async (categoryId: string) => {
    return db.product.findMany({
      where: { categoryId },
      include: { images: true },
    });
  },
  ["products-by-category"], // prefixo da chave de cache
  {
    revalidate: 3600, // revalida a cada hora
    tags: ["products"], // para revalidação sob demanda
  }
);
 
// Uso em um Server Component
async function ProductGrid({ categoryId }: { categoryId: string }) {
  const products = await getCachedProducts(categoryId);
  return <Grid items={products} />;
}

Para invalidar:

tsx
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function onProductUpdate() {
  revalidateTag("products");
}

Renderização Estática vs Dinâmica#

O Next.js decide se uma rota é estática ou dinâmica com base no que você usa nela:

Estática (renderizada no build time, cacheada):

  • Sem funções dinâmicas (cookies(), headers(), searchParams)
  • Todas as chamadas fetch com caching habilitado
  • Sem export const dynamic = "force-dynamic"

Dinâmica (renderizada por requisição):

  • Usa cookies(), headers() ou searchParams
  • Usa fetch com cache: "no-store"
  • Tem export const dynamic = "force-dynamic"
  • Usa connection() ou after() de next/server

Você pode verificar quais rotas são estáticas vs dinâmicas rodando next build — ele mostra uma legenda no final:

Route (app)                    Size     First Load JS
┌ ○ /                          5.2 kB   92 kB
├ ○ /about                     1.1 kB   88 kB
├ ● /blog/[slug]               2.3 kB   89 kB
├ λ /dashboard                 4.1 kB   91 kB
└ λ /api/products              0 B      87 kB

○ Static   ● SSG   λ Dynamic

A Hierarquia de Cache#

Pense no caching em camadas:

1. React cache()          — por requisição, em memória, dedup automática
2. fetch() cache          — entre requisições, automático para requests GET
3. unstable_cache()       — entre requisições, para operações não-fetch
4. Full Route Cache       — HTML renderizado cacheado no build/revalidação
5. Router Cache (client)  — cache no navegador de rotas visitadas

Cada camada serve um propósito diferente. Você nem sempre precisa de todas, mas entender qual está ativa ajuda a debugar problemas do tipo "por que meus dados não estão atualizando?".

Uma Estratégia de Cache do Mundo Real#

Aqui está o que eu realmente faço em produção:

tsx
// lib/data/products.ts
 
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
 
// Dedup por requisição: chame isso múltiplas vezes em um render,
// apenas uma query ao banco é executada
export const getProductById = cache(async (id: string) => {
  return db.product.findUnique({
    where: { id },
    include: { category: true, images: true },
  });
});
 
// Cache entre requisições: resultados persistem entre requisições,
// revalida a cada 5 minutos ou sob demanda via tag
export const getPopularProducts = unstable_cache(
  async () => {
    return db.product.findMany({
      orderBy: { salesCount: "desc" },
      take: 20,
      include: { images: true },
    });
  },
  ["popular-products"],
  { revalidate: 300, tags: ["products"] }
);
 
// Sem caching: sempre fresco (para dados específicos do usuário)
export const getUserCart = cache(async (userId: string) => {
  // cache() aqui é apenas para dedup por requisição, não persistência
  return db.cart.findUnique({
    where: { userId },
    include: { items: { include: { product: true } } },
  });
});

A regra geral: dados públicos que mudam com pouca frequência recebem unstable_cache. Dados específicos do usuário recebem cache() apenas para dedup. Dados em tempo real não recebem cache nenhum.

Server Actions: A Ponte de Volta#

Server Actions merecem sua própria seção porque completam a história do RSC. São como os Client Components se comunicam de volta com o servidor sem API routes.

tsx
// app/actions/newsletter.ts
"use server";
 
import { db } from "@/lib/database";
import { z } from "zod";
 
const emailSchema = z.string().email();
 
export async function subscribeToNewsletter(formData: FormData) {
  const email = formData.get("email");
 
  const result = emailSchema.safeParse(email);
  if (!result.success) {
    return { error: "Invalid email address" };
  }
 
  try {
    await db.subscriber.create({
      data: { email: result.data },
    });
    return { success: true };
  } catch (e) {
    if (e instanceof Error && e.message.includes("Unique constraint")) {
      return { error: "Already subscribed" };
    }
    return { error: "Something went wrong" };
  }
}
tsx
// src/components/NewsletterForm.tsx
"use client";
 
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
 
export function NewsletterForm() {
  const [state, formAction, isPending] = useActionState(
    async (_prev: { error?: string; success?: boolean } | null, formData: FormData) => {
      return subscribeToNewsletter(formData);
    },
    null
  );
 
  return (
    <form action={formAction}>
      <input
        type="email"
        name="email"
        required
        placeholder="you@example.com"
        disabled={isPending}
      />
      <button type="submit" disabled={isPending}>
        {isPending ? "Subscribing..." : "Subscribe"}
      </button>
      {state?.error && <p className="text-red-500">{state.error}</p>}
      {state?.success && <p className="text-green-500">Subscribed!</p>}
    </form>
  );
}

Server Actions são a resposta para "como eu faço mutação de dados?" no mundo RSC. Eles substituem a maioria das API routes para envios de formulário, mutações e efeitos colaterais.

Regras chave para Server Actions:

  • Sempre valide o input (a função é chamável do client — trate como um endpoint de API)
  • Sempre retorne dados serializáveis
  • Eles rodam no servidor, então você pode acessar bancos de dados, file systems, secrets
  • Eles podem chamar revalidatePath() ou revalidateTag() para atualizar dados em cache após mutações

Padrões de Migração#

Se você tem um app React existente (Pages Router, Create React App, Vite), mover para RSC não precisa ser uma reescrita. Aqui está como eu abordo isso.

Passo 1: Mapeie Seus Componentes#

Percorra sua árvore de componentes e classifique tudo:

Componente           State?  Effects?  Events?  → Decisão
─────────────────────────────────────────────────────────
Header               Não     Não       Não      → Server
NavigationMenu       Não     Não       Sim      → Client (toggle mobile)
Footer               Não     Não       Não      → Server
BlogPost             Não     Não       Não      → Server
SearchBar            Sim     Sim       Sim      → Client
ProductCard          Não     Não       Sim      → Client (onClick) ou dividir
UserAvatar           Não     Não       Não      → Server
CommentForm          Sim     Sim       Sim      → Client
Sidebar              Sim     Não       Sim      → Client (toggle de colapsar)
MarkdownRenderer     Não     Não       Não      → Server (grande ganho de dependência)
DataTable            Sim     Sim       Sim      → Client (ordenação, filtragem)

Passo 2: Mova o Data Fetching para Cima#

A maior mudança arquitetural é mover o data fetching de useEffect nos componentes para Server Components async. É aqui que o esforço real de migração está.

Antes:

tsx
// Padrão antigo — data fetching em um Client Component
"use client";
 
function ProductPage({ id }: { id: string }) {
  const [product, setProduct] = useState<Product | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    fetch(`/api/products/${id}`)
      .then(res => res.json())
      .then(data => setProduct(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [id]);
 
  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  if (!product) return <NotFound />;
 
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} />
    </div>
  );
}

Depois:

tsx
// Padrão novo — Server Component busca, Client Component interage
 
// app/products/[id]/page.tsx (Server Component)
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
import { AddToCartButton } from "@/components/AddToCartButton";
 
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } });
 
  if (!product) notFound();
 
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} price={product.price} />
    </div>
  );
}

Sem gerenciamento de estado de loading. Sem estado de erro. Sem useEffect. O framework lida com tudo isso através de Suspense e error boundaries.

Passo 3: Divida Componentes nas Fronteiras de Interação#

Muitos componentes são na maior parte estáticos com uma pequena parte interativa. Divida-os:

Antes (um grande Client Component):

tsx
"use client";
 
function ProductCard({ product }: { product: Product }) {
  const [isFavorite, setIsFavorite] = useState(false);
 
  return (
    <div className="card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <span>${product.price}</span>
      <button onClick={() => setIsFavorite(!isFavorite)}>
        {isFavorite ? "♥" : "♡"}
      </button>
    </div>
  );
}

Depois (Server Component com uma pequena ilha Client):

tsx
// ProductCard.tsx (Server Component)
import { FavoriteButton } from "./FavoriteButton";
 
function ProductCard({ product }: { product: Product }) {
  return (
    <div className="card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <span>${product.price}</span>
      <FavoriteButton productId={product.id} />
    </div>
  );
}
tsx
// FavoriteButton.tsx (Client Component)
"use client";
 
import { useState } from "react";
 
export function FavoriteButton({ productId }: { productId: string }) {
  const [isFavorite, setIsFavorite] = useState(false);
 
  return (
    <button
      onClick={() => setIsFavorite(!isFavorite)}
      aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
    >
      {isFavorite ? "♥" : "♡"}
    </button>
  );
}

A imagem, título, descrição e preço agora são renderizados no servidor. Apenas o pequeno botão de favoritar é um Client Component. Menos JavaScript, carregamento de página mais rápido.

Passo 4: Converta API Routes em Server Actions#

Se você tem API routes que existem apenas para servir seu próprio frontend (não consumidores externos), a maioria pode se tornar Server Actions:

Antes:

tsx
// app/api/contact/route.ts
export async function POST(request: Request) {
  const body = await request.json();
  // valida, salva no DB, envia email
  return Response.json({ success: true });
}
 
// Client component
const response = await fetch("/api/contact", {
  method: "POST",
  body: JSON.stringify(formData),
});

Depois:

tsx
// app/actions/contact.ts
"use server";
 
export async function submitContactForm(formData: FormData) {
  // valida, salva no DB, envia email
  return { success: true };
}
 
// Client component — apenas chame a função diretamente
import { submitContactForm } from "@/app/actions/contact";

Mantenha API routes para: webhooks, consumidores de API externos, qualquer coisa que precise de headers HTTP customizados ou status codes, uploads de arquivo com streaming.

Passo 5: Testando Componentes RSC#

Testar Server Components requer uma abordagem ligeiramente diferente já que podem ser async:

tsx
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
 
// Mock do banco de dados
vi.mock("@/lib/database", () => ({
  db: {
    product: {
      findUnique: vi.fn(),
    },
  },
}));
 
describe("ProductPage", () => {
  it("renders product details", async () => {
    const { db } = await import("@/lib/database");
    vi.mocked(db.product.findUnique).mockResolvedValue({
      id: "1",
      name: "Test Product",
      description: "A great product",
      price: 29.99,
    });
 
    // Server Components são async — faça await do JSX
    const jsx = await ProductPage({
      params: Promise.resolve({ id: "1" })
    });
    render(jsx);
 
    expect(screen.getByText("Test Product")).toBeInTheDocument();
    expect(screen.getByText("A great product")).toBeInTheDocument();
  });
});

A diferença chave: você faz await da função do componente porque ela é async. Depois renderiza o JSX resultante. Todo o resto funciona igual ao React Testing Library tradicional.

Estrutura de Arquivos para Projetos RSC#

Aqui está a estrutura na qual convergi depois de vários projetos. É opinativa, mas funciona:

src/
├── app/
│   ├── layout.tsx              ← Root layout (Server Component)
│   ├── page.tsx                ← Home page (Server Component)
│   ├── (marketing)/            ← Grupo de rotas para páginas de marketing
│   │   ├── about/page.tsx
│   │   └── pricing/page.tsx
│   ├── (app)/                  ← Grupo de rotas para app autenticado
│   │   ├── layout.tsx          ← App shell com verificação de auth
│   │   ├── dashboard/
│   │   │   ├── page.tsx
│   │   │   ├── loading.tsx     ← Fallback de Suspense para essa rota
│   │   │   └── error.tsx       ← Error boundary para essa rota
│   │   └── settings/
│   │       └── page.tsx
│   └── actions/                ← Server Actions
│       ├── auth.ts
│       └── products.ts
├── components/
│   ├── ui/                     ← Primitivas de UI compartilhadas (maioria Client)
│   │   ├── Button.tsx          ← "use client"
│   │   ├── Dialog.tsx          ← "use client"
│   │   └── Card.tsx            ← Server Component (apenas estilização)
│   └── features/               ← Componentes específicos de funcionalidades
│       ├── products/
│       │   ├── ProductGrid.tsx     ← Server (async, busca dados)
│       │   ├── ProductCard.tsx     ← Server (apresentacional)
│       │   ├── ProductSearch.tsx   ← Client (useState, input)
│       │   └── AddToCart.tsx       ← Client (onClick, mutação)
│       └── blog/
│           ├── PostList.tsx        ← Server (async, busca dados)
│           ├── PostContent.tsx     ← Server (renderização de markdown)
│           └── CommentSection.tsx  ← Client (formulário, tempo real)
├── lib/
│   ├── data/                   ← Camada de acesso a dados
│   │   ├── products.ts         ← Queries ao DB envolvidas com cache()
│   │   └── users.ts
│   ├── database.ts
│   └── utils.ts
└── providers/
    ├── ThemeProvider.tsx        ← "use client" — envolve partes que precisam de tema
    └── CartProvider.tsx         ← "use client" — envolve apenas a seção de loja

Os princípios chave:

  • Server Components não têm diretiva — são o padrão
  • Client Components são explicitamente marcados — você consegue ver de relance
  • Data fetching vive em lib/data/ — envolvido com cache() ou unstable_cache
  • Server Actions vivem em app/actions/ — co-localizados com o app, claramente separados
  • Providers envolvem o mínimo necessário — não o app inteiro

Conclusão#

React Server Components não são apenas uma nova API. São uma forma diferente de pensar sobre onde o código roda, onde os dados vivem e como as peças se conectam. A mudança de modelo mental é real e leva tempo.

Mas uma vez que faz sentido — uma vez que você para de lutar contra a fronteira e começa a projetar ao redor dela — você acaba com apps que são mais rápidos, mais simples e mais fáceis de manter do que o que estávamos construindo antes. Menos JavaScript é enviado ao client. Data fetching não requer cerimônia. A árvore de componentes se torna a arquitetura.

A transição vale a pena. Apenas saiba que os primeiros projetos vão parecer desconfortáveis, e isso é normal. Você não está lutando porque RSC é ruim. Você está lutando porque é genuinamente novo.

Comece com Server Components em todo lugar. Empurre "use client" para as folhas. Envolva coisas lentas em Suspense. Busque dados onde eles são renderizados. Componha através de children.

Esse é o playbook inteiro. Todo o resto são detalhes.

Posts Relacionados