Alternar tema

Next.js App Router + shadcn/ui: guia para combinar Server e Client Components

Easton editorial illustration: cache waterfall instrument

Uma mensagem de erro aparece na tela: Error: You're importing a component that needs useEffect. It only works in a Client Component but none of its parents are marked with "use client".

Você já adicionou "use client" ao layout.tsx. Então por que o erro continua?

Depois de vasculhar a documentação, você descobre que o problema está no limite definido pela cadeia de importações dos componentes. No App Router, a separação entre Server Components e Client Components é muito mais complexa do que parece.

Esse é um cenário real que muitos desenvolvedores enfrentam ao migrar para o App Router. Por padrão, todos os componentes são Server Components, mas a maior parte das bibliotecas de UI, como o shadcn/ui, precisa de Client Components. Como definir esse limite? Como os dados devem circular? E como otimizar o desempenho?

Este artigo explica cada um desses pontos.


Server Components vs. Client Components: a diferença fundamental

Primeiro, o princípio mais básico: no App Router, todos os componentes são Server Components por padrão.

O que isso significa? Seus arquivos page.tsx e layout.tsx são renderizados no servidor por padrão e não enviam nenhum código JavaScript ao navegador.

O que os Server Components podem fazer

A principal vantagem dos Server Components é estarem “mais perto dos dados”:

// app/products/page.tsx - Server Component (padrão)
async function ProductsPage() {
  // Aguarda os dados diretamente no componente
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 } // Cache por 1 hora
  }).then(res => res.json())

  return (
    <div>
      {products.map(p => (
        <div key={p.id}>{p.name} - ${p.price}</div>
      ))}
    </div>
  )
}

Observe: não há useEffect nem useState. Você obtém os dados diretamente com await. Essa é a característica de “componente assíncrono” dos Server Components.

Quando usar:

  • Busca de dados (fetch, consultas ao banco de dados)
  • Acesso a APIs exclusivas do backend (headers(), cookies())
  • Bibliotecas pesadas (por exemplo, um parser de Markdown com mais de 100 KB, que não precisa ser empacotado para o navegador quando usado em um Server Component)
  • Tratamento de informações sensíveis (a API key nunca fica exposta no frontend)

O que os Client Components podem fazer

Client Components são os “componentes React tradicionais” que já conhecemos. Basta adicionar "use client" no topo do arquivo:

// components/like-button.tsx
'use client'

import { useState } from 'react'

export function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false)
  const [count, setCount] = useState(0)

  const handleClick = () => {
    setLiked(!liked)
    setCount(prev => liked ? prev - 1 : prev + 1)
  }

  return (
    <button onClick={handleClick}>
      {liked ? '❤️' : '🤍'} {count}
    </button>
  )
}

Quando usar:

  • Tratamento de eventos (onClick, onChange, onSubmit)
  • Hooks do React (useState, useEffect, useRef, useContext)
  • APIs do navegador (localStorage, window, document)
  • Context Provider

Há um detalhe que parece contraintuitivo: os Client Components também têm seu HTML pré-renderizado no servidor. Depois, eles passam pelo processo de hydrate no navegador para recuperar a capacidade de interação. Portanto, na primeira visita, o usuário ainda vê o conteúdo completo, sem encarar uma “tela em branco enquanto o JavaScript carrega”.


Regra central: quem pode importar quem

É aqui que os erros aparecem com mais frequência.

A regra é simples, mas muita gente a memoriza ao contrário:

  1. Um Server Component pode importar um Client Component
  2. Um Client Component não pode importar um Server Component
  3. Um Server Component pode ser passado como children para um Client Component

A terceira regra pode parecer confusa. O código deixa isso mais claro:

// app/page.tsx - Server Component
import { ClientContainer } from './client-container'
import { ServerData } from './server-data'

export default function Page() {
  return (
    <ClientContainer>
      {/* ServerData é passado como children */}
      <ServerData />
    </ClientContainer>
  )
}

// client-container.tsx
'use client'

export function ClientContainer({ children }) {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}
    </div>
  )
}

// server-data.tsx - Server Component
async function ServerData() {
  const data = await fetch('/api/data').then(r => r.json())
  return <div>{data.title}</div>
}

Esse padrão é muito comum: Client Container cuida da lógica de interação, enquanto Server Data busca os dados. Os dois ficam isolados por children, sem uma importação direta do Server Component pelo Client Component.


Integração com shadcn/ui: por que parece tão “complicada”

O shadcn/ui é uma das minhas bibliotecas de UI favoritas, mas usá-lo no App Router realmente exige alguns cuidados.

O motivo é simples: o shadcn/ui é baseado no Radix UI, e a maioria dos componentes usa hooks do React.

Componentes como Button, Dialog e Dropdown Menu usam internamente useState ou useEffect. Por isso, precisam ser Client Components.

Exemplo incorreto: usar shadcn/ui diretamente em um Server Component

// ❌ Incorreto: Server Component importa Client Component
import { Button } from '@/components/ui/button'

async function ProductPage() {
  const product = await fetchProduct()

  return (
    <div>
      <h1>{product.name}</h1>
      {/* Isso gera um erro: Button precisa de "use client" */}
      <Button onClick={() => addToCart(product.id)}>
        Add to Cart
      </Button>
    </div>
  )
}

A mensagem informa que Button usa useState e precisa ser marcado com "use client".

Solução correta 1: extraia a parte interativa para um Client Component

Esta é a solução mais comum e mais simples:

// app/product/page.tsx - Server Component
import { ProductInfo } from './product-info'
import { AddToCartButton } from './add-to-cart-button'

async function ProductPage({ params }) {
  const product = await fetchProduct(params.id)

  return (
    <div>
      {/* Server Component: responsável por exibir os dados */}
      <ProductInfo product={product} />

      {/* Client Component: responsável pela interação */}
      <AddToCartButton productId={product.id} />
    </div>
  )
}

// product-info.tsx - Server Component
export function ProductInfo({ product }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <span>${product.price}</span>
    </div>
  )
}

// add-to-cart-button.tsx - Client Component
'use client'

import { Button } from '@/components/ui/button'
import { useState } from 'react'

export function AddToCartButton({ productId }) {
  const [loading, setLoading] = useState(false)

  const handleAdd = async () => {
    setLoading(true)
    await addToCart(productId)
    setLoading(false)
  }

  return (
    <Button onClick={handleAdd} disabled={loading}>
      {loading ? 'Adding...' : 'Add to Cart'}
    </Button>
  )
}

A ideia central é extrair apenas a parte que precisa de interação para um nó folha e manter todo o restante como Server Component.

Solução correta 2: padrão de composição (Server passa dados para Client)

Quando o Client Component precisa de dados iniciais:

// app/dashboard/page.tsx - Server Component
import { DataTable } from './data-table'

async function DashboardPage() {
  const users = await fetchUsers() // Server Component busca os dados

  return <DataTable data={users} /> // Passa para o Client Component
}

// data-table.tsx - Client Component
'use client'

import { Table } from '@/components/ui/table'
import { useState } from 'react'

export function DataTable({ data }) {
  const [selectedRows, setSelectedRows] = useState([])

  return (
    <Table>
      {/* Componente Table do shadcn/ui */}
      <TableBody>
        {data.map(user => (
          <TableRow
            key={user.id}
            selected={selectedRows.includes(user.id)}
            onClick={() => toggleSelection(user.id)}
          >
            <TableCell>{user.name}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

Assim, você aproveita a capacidade de buscar dados dos Server Components sem abrir mão da interatividade dos Client Components.


Onde colocar o Context Provider

Outra dúvida comum: onde devem ficar os Context Providers globais, como ThemeProvider e AuthProvider?

A resposta: eles precisam ficar em um Client Component, mas o mais profundamente possível na árvore.

// app/layout.tsx - Server Component (root layout)
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {/* Não coloque o Provider aqui */}
        {children}
      </body>
    </html>
  )
}

// app/providers.tsx - Client Component
'use client'

import { ThemeProvider } from 'next-themes'
import { AuthProvider } from './auth-context'

export function Providers({ children }) {
  return (
    <ThemeProvider>
      <AuthProvider>
        {children}
      </AuthProvider>
    </ThemeProvider>
  )
}

// app/dashboard/layout.tsx - Server Component
import { Providers } from '../providers'

export default function DashboardLayout({ children }) {
  return (
    <Providers>
      {children}
    </Providers>
  )
}

Por que colocá-lo em um nível “profundo”? Porque o Provider faz com que todos os componentes que envolve façam parte da subárvore de Client Components. Se ele ficar no root layout, toda a aplicação será forçada a renderizar no cliente.

Ao colocá-lo em um nível mais profundo, como o layout de uma rota específica, você minimiza o alcance do Provider.


Fluxo de dados: do Server para o Client

Props são a maneira mais simples e confiável:

// Server Component busca os dados
const data = await fetchData()

// Passa para o Client Component
<ClientComponent initialData={data} />

Mas há um recurso importante para otimizar o desempenho: a função React.cache().

Se vários Server Components precisarem dos mesmos dados, você pode usar cache para evitar requisições repetidas:

// lib/get-user.ts
import { cache } from 'react'

export const getUser = cache(async (id: string) => {
  return await db.query('SELECT * FROM users WHERE id = ?', [id])
})

// app/layout.tsx
async function Layout() {
  const user = await getUser('123') // Primeira requisição
  return <header>{user.name}</header>
}

// app/page.tsx
async function Page() {
  const user = await getUser('123') // Mesmo argumento, sem repetir a requisição
  return <main>Welcome {user.name}</main>
}

Durante um único ciclo de renderização, cache deduplica automaticamente as chamadas que usam os mesmos argumentos.


Os quatro erros mais comuns

Erro 1: usar “use client” em um nível muito alto

// ❌ app/layout.tsx recebeu "use client"
'use client'

export default function Layout({ children }) {
  return <div>{children}</div>
}

Isso transforma toda a subárvore da aplicação em Client Components e elimina as vantagens de desempenho dos Server Components.

Correção: adicione "use client" apenas aos componentes que realmente precisam de interação e mantenha-os nos nós folha.

Erro 2: usar hooks em um Server Component

// ❌ Server Component usa useState
async function Page() {
  const [count, setCount] = useState(0) // Erro!
  return <div>{count}</div>
}

Correção: extraia a parte que precisa de hooks para um Client Component.

Erro 3: usar headers()/cookies() em um Client Component

// ❌ Client Component usa uma API do servidor
'use client'

import { headers } from 'next/headers'

function UserProfile() {
  const headersList = headers() // Erro! Só pode ser usado em Server Component
  return <div>...</div>
}

Correção: busque os dados no Server Component e passe-os ao Client Component:

// Server Component obtém os headers
async function Page() {
  const userAgent = headers().get('user-agent')
  return <UserProfile userAgent={userAgent} />
}

// Client Component recebe os dados
'use client'
function UserProfile({ userAgent }) {
  return <div>Browser: {userAgent}</div>
}

Erro 4: componente de terceiros sem “use client”

// ❌ Server Component importa componente de terceiros sem marcação
import { AcmeCarousel } from 'acme-carousel'

async function Page() {
  return <AcmeCarousel /> // Erro! AcmeCarousel usa hooks internamente
}

Correção: crie um wrapper:

// components/carousel-wrapper.tsx
'use client'

import { AcmeCarousel } from 'acme-carousel'

export function CarouselWrapper(props) {
  return <AcmeCarousel {...props} />
}

// page.tsx - Server Component
import { CarouselWrapper } from './carousel-wrapper'

async function Page() {
  return <CarouselWrapper /> // Funciona normalmente
}

Recomendações para otimizar o desempenho

Por fim, algumas dicas práticas:

1. Coloque os Client Components nos nós folha

Essa regra pode reduzir em 70% o JavaScript enviado ao cliente.

Em uma página com uma lista de produtos, por exemplo:

  • Grade de produtos: Server Component
  • Cada card de produto: Server Component
  • Seletor de quantidade do card: Client Component (a única parte interativa)

2. Use Suspense para renderização em streaming

// app/page.tsx
import { Suspense } from 'react'
import { ProductList } from './product-list'
import { Recommendations } from './recommendations'

export default function Page() {
  return (
    <div>
      {/* Exibe o esqueleto primeiro e o substitui quando os dados chegam */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductList />
      </Suspense>

      {/* Conteúdo secundário renderizado em um stream independente */}
      <Suspense fallback={<RecSkeleton />}>
        <Recommendations />
      </Suspense>
    </div>
  )
}

O usuário vê primeiro a estrutura da página, e os dados são preenchidos aos poucos. A experiência é muito melhor do que “esperar todos os dados carregarem”.

3. Estratégia de cache do fetch

// Dados estáticos (obtidos durante o build)
await fetch(url, { cache: 'force-cache' })

// ISR: revalidação a cada hora
await fetch(url, { next: { revalidate: 3600 } })

// Dados dinâmicos (obtidos a cada requisição)
await fetch(url, { cache: 'no-store' })

Escolha a estratégia de cache adequada para evitar renderização dinâmica desnecessária.


Resumo

Depois de todos esses detalhes, os princípios centrais são poucos:

  1. Use Server Components por padrão e Client Components apenas quando houver necessidade de interação
  2. Server pode importar Client, mas Client não pode importar Server
  3. Passe dados por children ou props para manter limites claros
  4. Adicione “use client” nos nós folha, sem usá-lo indiscriminadamente nos níveis mais altos
  5. Extraia os componentes do shadcn/ui, em vez de misturá-los diretamente em um Server Component

O limite entre Server e Client no App Router foi pensado para deixar o desenvolvedor “mais perto dos dados e mais longe do navegador”. Quando você entende esse princípio, muitas dúvidas desaparecem.

Comece a praticar em uma página simples: primeiro escreva um Server Component que busca os dados e, depois, adicione aos poucos as partes interativas. Quando surgir um erro, não entre em pânico. Quase sempre é um problema de limite entre componentes; verifique a relação entre as importações e você provavelmente encontrará a causa rapidamente.


Série: este artigo faz parte da série Guia completo de Next.js (artigo 46). Se você está aprendendo Next.js App Router, confira os outros artigos da série. Para mais dicas práticas sobre shadcn/ui, leia a série Guia prático de Tailwind e shadcn/ui.

Como combinar Server e Client Components corretamente

Boas práticas para integrar o shadcn/ui a um projeto com Next.js App Router

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Identifique o tipo necessário para cada componente

    Determine se cada componente precisa de recursos interativos:

    • Precisa tratar eventos (onClick, onChange) → Client Component
    • Precisa de hooks do React (useState, useEffect) → Client Component
    • Precisa de APIs do navegador (localStorage, window) → Client Component
    • Apenas exibe dados, sem interação → Server Component (padrão)
  2. 2

    Step 2: Extraia a parte interativa para um nó folha

    Extraia apenas a parte que precisa de interação para um Client Component:

    • Crie um arquivo e adicione 'use client' no topo
    • Importe os componentes do shadcn/ui (Button, Dialog etc.)
    • Importe esse Client Component no Server Component
    • Passe os dados por props
  3. 3

    Step 3: Projete o fluxo de dados

    O Server Component busca os dados e os passa ao Client Component:

    • Use async/await no Server Component para buscar os dados
    • Passe-os ao Client Component por props
    • Quando vários lugares precisarem dos mesmos dados, use React.cache() para evitar requisições repetidas
    • Evite usar headers()/cookies() diretamente em um Client Component
  4. 4

    Step 4: Posicione o Context Provider

    O Provider precisa ser um Client Component, mas deve ficar em um layout mais profundo:

    • Crie providers.tsx e marque-o com 'use client'
    • Envolva ThemeProvider, AuthProvider e outros providers
    • Importe-o no layout.tsx de uma rota específica (não no root layout)
    • Minimize o alcance da subárvore de Client Components
  5. 5

    Step 5: Valide e otimize

    Verifique se os limites entre os componentes estão corretos:

    • Garanta que 'use client' apareça apenas nos nós folha
    • Confirme que nenhum Client Component importa um Server Component
    • Envolva componentes assíncronos com Suspense
    • Configure uma estratégia de cache adequada para fetch

FAQ

Por que os componentes do shadcn/ui precisam ser Client Components?
O shadcn/ui é baseado no Radix UI, e a maioria de seus componentes usa internamente hooks do React, como useState e useContext, para gerenciar estado e tratar eventos interativos. Esses hooks só podem ser executados no navegador, por isso o componente precisa ser marcado com 'use client'.
Server Components e Client Components podem importar uns aos outros?
Um Server Component pode importar um Client Component, mas um Client Component não pode importar um Server Component. No entanto, você pode passar um Server Component como conteúdo para um Client Component por meio da propriedade children, permitindo que os dois trabalhem juntos.
Como evitar a repetição da mesma requisição em vários Server Components?
Envolva a lógica de busca de dados com a função React.cache(). Chamadas com os mesmos argumentos são deduplicadas automaticamente durante um único ciclo de renderização, evitando consultas repetidas ao banco de dados ou à API.
Onde devo colocar um Context Provider?
O Provider precisa ser um Client Component, pois depende do React Context, mas não deve ficar no root layout. Importe-o no layout.tsx de uma rota específica para minimizar o alcance da subárvore de Client Components e preservar as vantagens dos Server Components no restante da aplicação.
O que fazer ao encontrar o erro de que useEffect só pode ser usado em um Client Component?
Verifique a cadeia de importações do componente indicado pelo erro. Encontre o componente que usa hooks ou trata eventos e adicione 'use client' no topo do arquivo. Se for um componente de terceiros sem essa marcação, crie um componente wrapper marcado com 'use client' e faça a importação dentro dele.
Como decidir se um componente deve ser Server ou Client Component?
Use uma regra simples: se precisa de interações como onClick ou onChange, use Client; se precisa de hooks como useState ou useEffect, use Client; se precisa de APIs do navegador como localStorage ou window, use Client. Nos demais casos, use Server Component por padrão.

11 min de leitura · Publicado em: 31 mar 2026 · Atualizado em: 4 set 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog