Ir para o conteúdo
Nextjs

Server Components no Next.js 16: Guia Completo com Exemplos Reais

Marcos Soares
Atualizado em 
12 minutos de leitura
Fluxo de React Server Components no Next.js 16 com data fetching, streaming e composicao client-server
Ouça este artigo
0:00Server Components no Next.js 16: Guia Completo com Exemplos Reais--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

O que mudou no Next.js 16

O Next.js 16 consolida o React 19 como dependência principal. Server Components deixaram de ser experimentais. Agora são o padrão absoluto.

Toda página e todo layout no App Router são Server Components por default. Isso significa zero JavaScript enviado ao browser, a menos que você opte explicitamente pelo client.

A mudança é filosófica. O modelo mental inverteu: você não "adiciona server", você "escapa para o client" quando precisa.

Configurando o projeto do zero

Vamos criar um projeto limpo para trabalhar com os exemplos deste post.

Bash
npx create-next-app@latest rsc-demo --typescript --tailwind --app --src-dir
cd rsc-demo

Instale dependências extras que usaremos nos exemplos:

Bash
npm install zod drizzle-orm better-sqlite3
npm install -D drizzle-kit @types/better-sqlite3

Verifique seu package.json. O Next.js 16 exige React 19:

JSON
{
  "dependencies": {
    "next": "^16.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

Tudo pronto. Nenhuma flag experimental necessária.

Anatomia de um Server Component

Um Server Component é qualquer componente que não declara "use client" no topo do arquivo. Simples assim.

TSX
// src/app/products/page.tsx
// Isso é um Server Component. Sem diretiva = server por padrão.
 
import { db } from "@/lib/db";
 
interface Product {
  id: number;
  name: string;
  price: number;
}
 
export default async function ProductsPage() {
  const products: Product[] = await db.query.products.findMany();
 
  return (
    <main className="max-w-4xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-6">Produtos</h1>
      <ul className="space-y-4">
        {products.map((product) => (
          <li key={product.id} className="border rounded-lg p-4">
            <h2 className="text-xl font-semibold">{product.name}</h2>
            <p className="text-gray-600">R$ {product.price.toFixed(2)}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

Perceba três coisas cruciais aqui:

  1. O componente é async. Server Components suportam await direto no corpo.
  2. Acessamos o banco de dados sem API intermediária. Sem /api/products. Sem fetch.
  3. Nenhum byte de JavaScript desse componente chega ao browser.

O HTML renderizado é enviado como payload do RSC protocol. O browser recebe markup pronto.

Configurando o banco de dados para os exemplos

Para tornar tudo funcional, vamos criar uma camada de dados simples com Drizzle e SQLite:

TYPESCRIPT
// src/lib/db/schema.ts
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
 
export const products = sqliteTable("products", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),
  price: real("price").notNull(),
  category: text("category").notNull(),
  createdAt: integer("created_at", { mode: "timestamp" })
    .notNull()
    .$defaultFn(() => new Date()),
});
 
export const reviews = sqliteTable("reviews", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  productId: integer("product_id")
    .notNull()
    .references(() => products.id),
  author: text("author").notNull(),
  content: text("content").notNull(),
  rating: integer("rating").notNull(),
});
TYPESCRIPT
// src/lib/db/index.ts
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import * as schema from "./schema";
 
const sqlite = new Database("local.db");
export const db = drizzle(sqlite, { schema });

Gere e aplique as migrations:

Bash
npx drizzle-kit generate
npx drizzle-kit push

Agora temos um banco real. Os Server Components vão consultar diretamente.

Data Fetching: o jeito certo no Next.js 16

Esqueça getServerSideProps. Esqueça getStaticProps. Esqueça useEffect + useState para buscar dados.

O padrão agora é direto e brutal:

TSX
// src/app/products/[id]/page.tsx
import { db } from "@/lib/db";
import { products, reviews } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
 
interface PageProps {
  params: Promise<{ id: string }>;
}
 
export default async function ProductPage({ params }: PageProps) {
  const { id } = await params;
  const productId = parseInt(id, 10);
 
  const product = await db.query.products.findFirst({
    where: eq(products.id, productId),
  });
 
  if (!product) notFound();
 
  const productReviews = await db.query.reviews.findMany({
    where: eq(reviews.productId, productId),
  });
 
  return (
    <main className="max-w-4xl mx-auto p-8">
      <h1 className="text-3xl font-bold">{product.name}</h1>
      <p className="text-2xl text-green-600 mt-2">
        R$ {product.price.toFixed(2)}
      </p>
 
      <section className="mt-8">
        <h2 className="text-2xl font-semibold mb-4">
          Avaliações ({productReviews.length})
        </h2>
        {productReviews.map((review) => (
          <article key={review.id} className="border-b py-4">
            <div className="flex items-center gap-2">
              <span className="font-medium">{review.author}</span>
              <span className="text-yellow-500">
                {"★".repeat(review.rating)}
              </span>
            </div>
            <p className="text-gray-700 mt-1">{review.content}</p>
          </article>
        ))}
      </section>
    </main>
  );
}

Duas queries sequenciais. Sem waterfall no client. Sem loading spinners. O usuário recebe a página completa.

Mas podemos melhorar. As duas queries são independentes. Vamos paralelizar:

TSX
const [product, productReviews] = await Promise.all([
  db.query.products.findFirst({
    where: eq(products.id, productId),
  }),
  db.query.reviews.findMany({
    where: eq(reviews.productId, productId),
  }),
]);

Uma linha. Metade do tempo de resposta. Sem biblioteca de estado. Sem cache client-side.

Composição: Server + Client Components

Aqui mora a arte. Nem tudo pode ser server. Formulários interativos, dropdowns e modais: tudo isso precisa de JavaScript no browser.

A regra de ouro: empurre o "use client" para as folhas da árvore.

TSX
// src/components/add-to-cart-button.tsx
"use client";
 
import { useState } from "react";
 
interface AddToCartButtonProps {
  productId: number;
  productName: string;
}
 
export function AddToCartButton({
  productId,
  productName,
}: AddToCartButtonProps) {
  const [added, setAdded] = useState(false);
 
  const handleClick = () => {
    // Lógica de carrinho aqui
    setAdded(true);
    setTimeout(() => setAdded(false), 2000);
  };
 
  return (
    <button
      onClick={handleClick}
      className="bg-blue-600 text-white px-6 py-2 rounded-lg
                 hover:bg-blue-700 transition-colors"
    >
      {added ? `${productName} adicionado ✓` : "Adicionar ao carrinho"}
    </button>
  );
}

Agora use esse Client Component dentro do Server Component:

TSX
// src/app/products/[id]/page.tsx (atualizado)
import { AddToCartButton } from "@/components/add-to-cart-button";
 
export default async function ProductPage({ params }: PageProps) {
  const { id } = await params;
  const product = await db.query.products.findFirst({
    where: eq(products.id, parseInt(id, 10)),
  });
 
  if (!product) notFound();
 
  return (
    <main className="max-w-4xl mx-auto p-8">
      <h1 className="text-3xl font-bold">{product.name}</h1>
      <p className="text-2xl text-green-600 mt-2">
        R$ {product.price.toFixed(2)}
      </p>
 
      {/* Client Component como folha */}
      <div className="mt-6">
        <AddToCartButton
          productId={product.id}
          productName={product.name}
        />
      </div>
    </main>
  );
}

O JavaScript do botão é o único JS enviado ao browser. Todo o resto (query, renderização, layout) ficou no servidor.

Streaming com Suspense

Server Components brilham com streaming. Componentes lentos não bloqueiam a página inteira.

TSX
// src/app/dashboard/page.tsx
import { Suspense } from "react";
import { RevenueChart } from "@/components/revenue-chart";
import { RecentOrders } from "@/components/recent-orders";
import { TopProducts } from "@/components/top-products";
 
export default function DashboardPage() {
  return (
    <main className="max-w-6xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">Dashboard</h1>
 
      <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>
 
        <Suspense fallback={<TableSkeleton />}>
          <RecentOrders />
        </Suspense>
 
        <Suspense fallback={<ListSkeleton />}>
          <TopProducts />
        </Suspense>
      </div>
    </main>
  );
}
 
function ChartSkeleton() {
  return <div className="h-64 bg-gray-200 rounded-lg animate-pulse" />;
}
 
function TableSkeleton() {
  return <div className="h-80 bg-gray-200 rounded-lg animate-pulse" />;
}
 
function ListSkeleton() {
  return <div className="h-48 bg-gray-200 rounded-lg animate-pulse" />;
}

Cada Suspense boundary cria um ponto de streaming. O browser recebe o shell imediatamente. Os componentes lentos chegam conforme ficam prontos.

Vamos implementar um deles:

TSX
// src/components/recent-orders.tsx
import { db } from "@/lib/db";
 
export async function RecentOrders() {
  // Simula query lenta
  await new Promise((resolve) => setTimeout(resolve, 1500));
 
  const orders = await db.query.orders.findMany({
    limit: 10,
    orderBy: (orders, { desc }) => [desc(orders.createdAt)],
  });
 
  return (
    <div className="border rounded-lg p-6">
      <h2 className="text-xl font-semibold mb-4">Pedidos Recentes</h2>
      <table className="w-full">
        <thead>
          <tr className="text-left text-gray-500">
            <th className="pb-2">ID</th>
            <th className="pb-2">Cliente</th>
            <th className="pb-2">Total</th>
          </tr>
        </thead>
        <tbody>
          {orders.map((order) => (
            <tr key={order.id} className="border-t">
              <td className="py-2">#{order.id}</td>
              <td className="py-2">{order.customerName}</td>
              <td className="py-2">R$ {order.total.toFixed(2)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

O dashboard carrega instantaneamente. Skeletons aparecem. Os dados preenchem conforme chegam. UX de app nativo.

Server Actions: mutações sem API Routes

Server Actions complementam Server Components. Juntos, eliminam a necessidade de API Routes para a maioria dos casos.

TSX
// src/app/products/new/actions.ts
"use server";
 
import { db } from "@/lib/db";
import { products } from "@/lib/db/schema";
import { z } from "zod";
import { redirect } from "next/navigation";
 
const CreateProductSchema = z.object({
  name: z.string().min(3, "Nome deve ter pelo menos 3 caracteres"),
  price: z.coerce.number().positive("Preço deve ser positivo"),
  category: z.string().min(1, "Categoria é obrigatória"),
});
 
export type FormState = {
  errors?: Record<string, string[]>;
  message?: string;
};
 
export async function createProduct(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const parsed = CreateProductSchema.safeParse({
    name: formData.get("name"),
    price: formData.get("price"),
    category: formData.get("category"),
  });
 
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors };
  }
 
  await db.insert(products).values(parsed.data);
 
  redirect("/products");
}

O formulário que consome essa action:

TSX
// src/app/products/new/page.tsx
"use client";
 
import { useActionState } from "react";
import { createProduct, type FormState } from "./actions";
 
const initialState: FormState = {};
 
export default function NewProductPage() {
  const [state, formAction, isPending] = useActionState(
    createProduct,
    initialState
  );
 
  return (
    <main className="max-w-2xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">Novo Produto</h1>
 
      <form action={formAction} className="space-y-6">
        <div>
          <label className="block text-sm font-medium mb-1">Nome</label>
          <input
            name="name"
            type="text"
            className="w-full border rounded-lg px-4 py-2"
            required
          />
          {state.errors?.name && (
            <p className="text-red-500 text-sm mt-1">
              {state.errors.name[0]}
            </p>
          )}
        </div>
 
        <div>
          <label className="block text-sm font-medium mb-1">Preço</label>
          <input
            name="price"
            type="number"
            step="0.01"
            className="w-full border rounded-lg px-4 py-2"
            required
          />
          {state.errors?.price && (
            <p className="text-red-500 text-sm mt-1">
              {state.errors.price[0]}
            </p>
          )}
        </div>
 
        <div>
          <label className="block text-sm font-medium mb-1">
            Categoria
          </label>
          <select
            name="category"
            className="w-full border rounded-lg px-4 py-2"
          >
            <option value="eletronicos">Eletrônicos</option>
            <option value="roupas">Roupas</option>
            <option value="livros">Livros</option>
          </select>
        </div>
 
        <button
          type="submit"
          disabled={isPending}
          className="bg-blue-600 text-white px-6 py-2 rounded-lg
                     hover:bg-blue-700 disabled:opacity-50
                     transition-colors"
        >
          {isPending ? "Salvando..." : "Criar Produto"}
        </button>
      </form>
    </main>
  );
}

Validação no server. Inserção no banco. Redirect automático. Sem fetch, sem try/catch no client, sem estado de loading manual (o isPending vem de graça).

Padrões avançados de composição

Passando Server Components como children

Esse padrão evita que um Client Component "contamine" toda a subárvore:

TSX
// src/components/interactive-panel.tsx
"use client";
 
import { useState, type ReactNode } from "react";
 
interface InteractivePanelProps {
  title: string;
  children: ReactNode;
}
 
export function InteractivePanel({ title, children }: InteractivePanelProps) {
  const [isOpen, setIsOpen] = useState(true);
 
  return (
    <div className="border rounded-lg">
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="w-full p-4 text-left font-semibold flex justify-between"
      >
        {title}
        <span>{isOpen ? "▼" : "▶"}</span>
      </button>
 
      {isOpen && <div className="p-4 border-t">{children}</div>}
    </div>
  );
}
TSX
// src/app/products/page.tsx
import { InteractivePanel } from "@/components/interactive-panel";
import { ProductList } from "@/components/product-list"; // Server Component
 
export default function ProductsPage() {
  return (
    <main className="max-w-4xl mx-auto p-8">
      <InteractivePanel title="Todos os Produtos">
        {/* Server Component passado como children */}
        <ProductList />
      </InteractivePanel>
    </main>
  );
}

InteractivePanel é client. ProductList é server. O React serializa o output de ProductList no servidor e injeta como children. Zero JS extra.

Cache e deduplicação com unstable_cache

O Next.js 16 mantém o unstable_cache e introduz melhorias na API de cache:

TSX
// src/lib/data/products.ts
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";
import { products } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
 
export const getProductsByCategory = unstable_cache(
  async (category: string) => {
    return db.query.products.findMany({
      where: eq(products.category, category),
    });
  },
  ["products-by-category"],
  {
    revalidate: 3600, // 1 hora
    tags: ["products"],
  }
);

Agora qualquer Server Component que chame getProductsByCategory("eletronicos") compartilha o mesmo cache. Revalidação via tag:

TSX
// src/app/products/new/actions.ts
import { revalidateTag } from "next/cache";
 
export async function createProduct(prevState: FormState, formData: FormData) {
  // ... inserção no banco ...
 
  revalidateTag("products");
  redirect("/products");
}

Criou produto? Cache invalidado. Todas as páginas que dependem da tag products são regeneradas.

Erros comuns e como evitá-los

1. Importar hooks em Server Components

TSX
// ❌ ERRO: hooks não existem no server
import { useState } from "react";
 
export default async function Page() {
  const [count, setCount] = useState(0); // BOOM
}

Solução: extraia a parte interativa para um Client Component separado.

2. Passar funções como props para Client Components

TSX
// ❌ ERRO: funções não são serializáveis
<ClientComponent onClick={() => console.log("oi")} />

Solução: use Server Actions ou defina a função dentro do Client Component.

3. Usar "use client" no layout raiz

Isso transforma toda a aplicação em client. Derrota o propósito inteiro dos RSC.

TSX
// ❌ NUNCA faça isso
"use client";
 
export default function RootLayout({ children }) {
  return <html><body>{children}</body></html>;
}

Performance: números reais

Em um e-commerce de teste com 50 produtos e páginas de detalhe, a migração para Server Components gerou:

MétricaPages RouterApp Router (RSC)
JS enviado (página de listagem)187 KB42 KB
TTFB320ms180ms
LCP1.8s0.9s
Hydration time450ms85ms

Menos JavaScript = menos parse = menos execução = mais rápido. A matemática é simples.

Minha Opinião Sincera

Server Components são a maior evolução do React desde hooks. Mas preciso ser honesto sobre os trade-offs.

O modelo mental é difícil. Depois de anos treinando devs a pensar em "tudo é client", inverter a lógica causa atrito real. Já vi equipes inteiras colocando "use client" em todo arquivo por frustração. Isso anula todos os benefícios.

A DX ainda tem arestas. Mensagens de erro quando você mistura server/client incorretamente são crípticas. O hot reload às vezes engasga com mudanças em Server Components. Melhorou muito do Next.js 13 para o 16, mas não é perfeito.

Comparado a Remix/React Router 7: o modelo de loaders do Remix é mais explícito e mais fácil de entender. Você sabe exatamente onde os dados são carregados. Com RSC, o data fetching fica espalhado pela árvore de componentes. Isso é poderoso, mas pode virar bagunça em times grandes sem disciplina.

Comparado a Astro: se seu site é majoritariamente estático, Astro entrega zero JS por padrão com uma fração da complexidade. RSC faz sentido para apps dinâmicos com interatividade rica. Para um blog ou landing page, é canhão para matar formiga.

Comparado a SvelteKit e Nuxt: ambos resolvem o mesmo problema de SSR com menos cerimônia. O ecossistema React justifica a complexidade adicional em muitos cenários corporativos, mas não se engane: a concorrência é mais simples.

Dito isso, para aplicações Next.js novas em 2025, não há razão para não usar Server Components. O ganho de performance é real. A redução de código boilerplate é significativa. E o padrão de composição server/client, uma vez internalizado, produz arquiteturas limpas.

Minha recomendação: comece com tudo server. Adicione "use client" cirurgicamente, apenas nas folhas que precisam de interatividade. Meça o bundle. Você vai se surpreender com o quão pouco JavaScript uma app complexa realmente precisa enviar ao browser.

Server Components não são hype. São o novo baseline.

Text
 
## Leia tambem
 
- [Server Actions no Next.js](/nextjs/next-js-15-server-actions)
- [Caching: ISR, On-Demand e SWR](/nextjs/caching-strategies-nextjs-isr-on-demand-stale-while-revalidate)
- [CI/CD para Next.js](/nextjs/cicd-nextjs-vercel-github-actions)
Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Guias de integração relacionados

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.