Alternar tema

Modo escuro no Next.js: guia completo com next-themes

Easton editorial illustration: one split light-and-dark browser card with a central theme toggle

Na primeira vez que implementei modo escuro em um projeto Next.js, tive uma péssima experiência. No instante em que a página carregava, aparecia um clarão branco antes da mudança para o modo escuro — aquele flash era enlouquecedor. Só percebi a gravidade do problema quando um usuário comentou que a tela quase tinha cegado seus olhos.

Depois disso, testei várias soluções: escrevi minha própria implementação, usei a biblioteca use-dark-mode e consultei inúmeros tutoriais. No fim, descobri que next-themes era a verdadeira salvação. Hoje uso a biblioteca em todos os projetos: não há flashes, a configuração é muito simples e o tema acompanha perfeitamente o sistema. Neste artigo, vou compartilhar os problemas que enfrentei e a solução que encontrei.

Por que escolhi next-themes

No começo, também fiquei em dúvida se deveria escrever minha própria lógica de troca de tema. Afinal, parecia simples: bastava ler o localStorage e alterar uma classe. Na prática, porém, percebi que a renderização no servidor do Next.js torna essa tarefa bem mais complexa.

Testei algumas opções:

Implementação própria: o maior problema era o flash. Durante a SSR, o servidor não conhece a preferência de tema do usuário e renderiza o tema claro padrão. O localStorage só pode ser lido durante a hydration no cliente; quando a página muda para o tema escuro, o flash fica bem visível.

use-dark-mode: a biblioteca é boa, mas não foi projetada especificamente para Next.js e ainda apresenta alguns problemas de compatibilidade em cenários com SSR.

theme-ui: oferece muitos recursos, mas é pesada demais para um caso de uso que só exige a troca para o modo escuro. O bundle também fica maior.

Foi então que encontrei next-themes: são mais de 6.000 estrelas no GitHub, zero dependências, menos de 1 KB compactado com gzip e uma arquitetura criada especificamente para Next.js. Mais importante: ela realmente elimina o flash, já oferece suporte ao tema do sistema e persiste a preferência automaticamente. O suporte a TypeScript também é excelente, o que deixa o uso bastante agradável.

Implementação completa, passo a passo

Instale a dependência

Como sempre, comece instalando o pacote:

npm install next-themes

Se você usa pnpm ou yarn, também funciona:

pnpm add next-themes
# ou
yarn add next-themes

Crie o componente ThemeProvider

Agora precisamos criar um componente Provider. Normalmente, crio uma pasta providers ou components no projeto para esse tipo de arquivo.

Crie providers/theme-provider.tsx:

'use client'

import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

É obrigatório adicionar 'use client', pois next-themes precisa acessar APIs do navegador. Esse foi o primeiro problema em que tropecei: omiti a diretiva no início e recebi vários erros de hydration.

Integre ao Layout

Agora adicione o ThemeProvider ao layout raiz. Se você usa o App Router (Next.js 13+), o arquivo deve ser app/layout.tsx:

import { ThemeProvider } from '@/providers/theme-provider'
import './globals.css'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="pt-BR" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

Há algumas configurações importantes aqui:

attribute="class": informa ao next-themes que o tema deve ser trocado alterando a classe do elemento <html>. Isso funciona muito bem com o prefixo dark: do Tailwind CSS.

defaultTheme="system": usa o tema do sistema como padrão. Na primeira visita, a preferência de tema do sistema operacional é detectada automaticamente.

enableSystem: ativa a detecção do tema do sistema. Essa opção precisa estar habilitada; caso contrário, defaultTheme="system" não terá efeito.

disableTransitionOnChange: desativa as animações de transição durante a troca. Você pode ajustar essa opção conforme suas necessidades, mas recomendo ativá-la. Quando há transições durante a mudança para o modo escuro, todos os elementos se animam juntos, e o resultado visual costuma ficar pior.

suppressHydrationWarning: esta propriedade, adicionada à tag <html>, é muito importante. next-themes altera a classe do elemento html antes da hydration no cliente; sem ela, o React exibe um aviso.

Crie o botão de troca de tema

Com o Provider pronto, já podemos criar o botão. Crie components/theme-toggle.tsx:

'use client'

import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'

export function ThemeToggle() {
  const [mounted, setMounted] = useState(false)
  const { theme, setTheme } = useTheme()

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) {
    return null
  }

  return (
    <button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
      aria-label="Trocar tema"
    >
      {theme === 'dark' ? '🌞' : '🌙'}
    </button>
  )
}

Há um pequeno truque aqui: retornar null até o componente terminar de carregar. Por quê? O tema não está disponível durante a renderização no servidor; renderizar o botão diretamente causaria uma incompatibilidade de hydration. Só depois que o componente é montado no cliente o useTheme consegue retornar o tema atual corretamente.

Se você quiser uma alternância entre três estados — light, dark e system — pode escrever assim:

export function ThemeToggle() {
  const [mounted, setMounted] = useState(false)
  const { theme, setTheme } = useTheme()

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) return null

  const cycleTheme = () => {
    if (theme === 'light') setTheme('dark')
    else if (theme === 'dark') setTheme('system')
    else setTheme('light')
  }

  const getIcon = () => {
    if (theme === 'light') return '🌞'
    if (theme === 'dark') return '🌙'
    return '💻'
  }

  return (
    <button
      onClick={cycleTheme}
      className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
    >
      {getIcon()}
    </button>
  )
}

Entendendo a fundo o problema do flash

O que me levou a estudar esse assunto foi justamente aquele flash irritante. Levei algum tempo para entender o mecanismo por completo.

Como o FOUC acontece

O FOUC (Flash of Unstyled Content, ou exibição momentânea de conteúdo sem estilo) é muito comum em implementações de modo escuro no Next.js. A causa está na diferença entre o estado da SSR e o estado do cliente.

Durante a renderização no servidor, o ambiente Node.js não tem o objeto window, não consegue acessar o localStorage e tampouco conhece a preferência de tema do sistema do usuário. Por isso, o servidor só pode renderizar um tema padrão, normalmente o claro.

Em seguida, o HTML chega ao navegador e começa a hydration, processo no qual o React transforma o HTML estático do servidor em componentes interativos. Só então o JavaScript consegue ler o localStorage, descobrir que o usuário havia escolhido o tema escuro e alterar o DOM para adicionar a classe dark.

Essa alteração provoca uma nova renderização, e todos os estilos precisam mudar do tema claro para o escuro. É assim que o flash aparece.

Como next-themes resolve o problema

A solução do next-themes é engenhosa: a biblioteca injeta um blocking script no <head>. Esse script é executado antes da renderização da página, lê imediatamente o tema salvo no localStorage e adiciona a classe correspondente ao elemento <html>.

De forma simplificada, a lógica se parece com isto:

(function() {
  try {
    const theme = localStorage.getItem('theme')
    const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
    const currentTheme = theme || systemTheme
    
    if (currentTheme === 'dark') {
      document.documentElement.classList.add('dark')
    }
  } catch (e) {}
})()

Como o script é síncrono e bloqueia a renderização, a classe correta já está definida antes que qualquer conteúdo seja exibido. Assim, o CSS aplica o estilo certo desde o início e não há flash.

Erros comuns de configuração

Já vi muitas configurações problemáticas, geralmente por causa destes pontos:

Esquecer suppressHydrationWarning:

Se você não adicionar essa propriedade à tag <html>, o console exibirá continuamente este aviso:

Warning: Prop `className` did not match. Server: "" Client: "dark"

Isso não impede o funcionamento, mas incomoda.

ThemeProvider no lugar errado:

Colocar o ThemeProvider dentro de um Server Component ou fora de body pode causar problemas. Lembre-se: ele precisa envolver o conteúdo da página e ser um Client Component.

Configuração incorreta do Tailwind:

Se o seu tailwind.config.js estiver assim:

module.exports = {
  darkMode: 'media',
}

Você terá um problema. O modo media é uma solução exclusivamente em CSS: ele só acompanha o tema do sistema e não permite troca manual. Altere para:

module.exports = {
  darkMode: 'class',
}

Persistência do tema e acompanhamento do sistema

Como funciona a persistência

Por padrão, next-themes salva a escolha de tema no localStorage usando a chave 'theme'. Isso acontece automaticamente, sem nenhum código adicional.

Se você quiser personalizar a chave de armazenamento, configure assim:

<ThemeProvider
  attribute="class"
  defaultTheme="system"
  enableSystem
  storageKey="my-theme"
>
  {children}
</ThemeProvider>

Em alguns cenários, talvez você precise usar um cookie em vez do localStorage. Por exemplo, se quiser que o servidor conheça a preferência de tema do usuário e elimine qualquer possibilidade de flash. Nesse caso, você pode:

  1. Ler o cookie no middleware e adicioná-lo ao cabeçalho da resposta
  2. Renderizar o tema correspondente no servidor com base nesse cabeçalho
  3. Sincronizar o cookie com o localStorage no cliente

Mas, sinceramente, a solução padrão do next-themes já é suficiente para a maioria dos casos.

Acompanhamento do tema do sistema

A opção enableSystem permite que next-themes acompanhe as mudanças de tema do sistema. Quando o usuário alterna entre os modos claro e escuro nas configurações do sistema operacional, o aplicativo também muda automaticamente se o tema atual estiver definido como system.

Por baixo dos panos, a biblioteca monitora a media query prefers-color-scheme:

window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    // Lógica de troca de tema
  })

O usuário também pode substituir manualmente a preferência do sistema. Por exemplo, mesmo com o sistema no modo claro, ele pode selecionar o tema escuro no seu site. next-themes memoriza essa escolha e mantém o tema escuro na próxima visita.

Suporte a vários temas

Embora o foco aqui seja o modo escuro, next-themes aceita qualquer quantidade de temas. Você pode criar, por exemplo, temas roxo e verde:

<ThemeProvider
  attribute="class"
  defaultTheme="system"
  enableSystem
  themes={['light', 'dark', 'purple', 'green']}
>
  {children}
</ThemeProvider>

Depois, defina os estilos correspondentes no CSS:

.purple {
  --background: #f3e8ff;
  --foreground: #581c87;
}

.green {
  --background: #dcfce7;
  --foreground: #14532d;
}

Essa abordagem fica muito flexível quando combinada com variáveis CSS.

Dicas práticas e problemas comuns

Uso com Tailwind CSS

Se você usa Tailwind, a configuração fica ainda mais simples. Primeiro, confira se tailwind.config.js contém:

module.exports = {
  darkMode: 'class',
  // Outras configurações...
}

Depois, use o prefixo dark: normalmente:

<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
  <h1 className="text-2xl font-bold">Título</h1>
  <p className="text-gray-600 dark:text-gray-400">Texto do parágrafo</p>
</div>

A variante dark: do Tailwind entra em vigor quando o elemento <html> tem a classe dark, exatamente como next-themes funciona.

Animações e transições

Pessoalmente, recomendo ativar disableTransitionOnChange. Se o seu CSS contém muitas propriedades transition, todos os elementos serão animados ao mesmo tempo durante a troca de tema, e o resultado pode ficar confuso.

Mas, se você realmente quiser um efeito de transição, use:

<ThemeProvider
  attribute="class"
  defaultTheme="system"
  enableSystem
  disableTransitionOnChange={false}
>
  {children}
</ThemeProvider>

E adicione ao CSS global:

* {
  transition: background-color 0.2s ease, color 0.2s ease;
}

Isso cria um efeito suave de fade durante a troca. Mesmo assim, depois de testar algumas vezes, continuo achando que a interface fica mais limpa e direta sem transição.

Suporte de tipos no TypeScript

O suporte a TypeScript do next-themes é muito bom. Para estender o tipo dos temas, você pode fazer assim:

import { useTheme } from 'next-themes'

type Theme = 'light' | 'dark' | 'purple'

export function useCustomTheme() {
  const { theme, setTheme } = useTheme()
  
  return {
    theme: theme as Theme,
    setTheme: (theme: Theme) => setTheme(theme),
  }
}

Assim, o editor oferece sugestões de tipo e impede que você defina por engano um tema inexistente.

Solução de problemas comuns

Problema 1: o tema muda, mas os estilos não

Confira estes pontos:

  • O darkMode do Tailwind está definido como 'class'?
  • O CSS usa corretamente o prefixo dark: ou o seletor .dark?
  • A classe foi adicionada corretamente ao elemento <html>? Confira no console do navegador.

Problema 2: a página ainda pisca ao atualizar

Se ainda houver flash, as causas possíveis são:

  • Você esqueceu de adicionar suppressHydrationWarning ao elemento <html>
  • O ThemeProvider está no lugar errado
  • Outro script está interferindo, como o Google Analytics

Problema 3: o tema não acompanha o sistema

Confira:

  • enableSystem está definido como true?
  • O navegador aceita prefers-color-scheme? Todos os navegadores modernos aceitam.
  • O tema atual está definido como system? Se houve uma troca manual, ele pode estar como light ou dark.

Conclusão

Relembrando o caminho desde a frustração inicial com o flash até uma implementação fluida de modo escuro, next-themes foi uma grande ajuda. A biblioteca não resolve apenas um problema técnico: ela também melhora a experiência do usuário.

Vamos recapitular os pontos principais:

  1. next-themes resolve o flash do modo escuro no Next.js praticamente sem configuração
  2. Adicione suppressHydrationWarning ao elemento <html> e marque o ThemeProvider como Client Component
  3. Defina darkMode como 'class' no Tailwind
  4. Renderize o botão de troca de tema somente depois de mounted para evitar incompatibilidade de hydration
  5. O acompanhamento do tema do sistema e a troca manual podem coexistir perfeitamente

Se você ainda não experimentou next-themes em um projeto, vale muito a pena testar. A documentação oficial também é bem clara: github.com/pacocoursey/next-themes

Agora é só adicionar um modo escuro fluido ao seu projeto Next.js. Seus usuários vão agradecer!

Processo completo para implementar modo escuro no Next.js

Use next-themes para criar um modo escuro sem flashes, com acompanhamento do tema do sistema e troca manual

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Instale next-themes

    Instale a dependência:
    • npm install next-themes

    Ou use outro gerenciador de pacotes:
    • pnpm add next-themes
    • yarn add next-themes

    Observação: next-themes não tem dependências e ocupa muito pouco espaço
  2. 2

    Step 2: Configure o ThemeProvider

    Adicione-o ao layout raiz:
    • Crie o arquivo providers.tsx (marcado com 'use client')
    • Envolva children com ThemeProvider
    • Importe e use o componente em app/layout.tsx

    Configurações principais:
    • attribute="class": usa uma classe para trocar o tema
    • enableSystem: ativa o acompanhamento do tema do sistema
    • storageKey: nome da chave no localStorage

    Observação: ThemeProvider precisa ser um Client Component
  3. 3

    Step 3: Configure o Tailwind CSS

    Em tailwind.config.js:
    • Defina darkMode: 'class'
    • Assim, o Tailwind troca o tema conforme a classe da tag html

    Exemplo de configuração:
    module.exports = {
    darkMode: 'class',
    // ... outras configurações
    }

    Use o prefixo dark: para definir estilos escuros:
    className="bg-white dark:bg-gray-900"
  4. 4

    Step 4: Corrija o aviso de hydration

    Adicione à tag html:
    • A propriedade suppressHydrationWarning
    • Ela evita o aviso causado pela diferença de tema entre servidor e cliente

    Em layout.tsx:
    <html lang="pt-BR" suppressHydrationWarning>
    <body>{children}</body>
    </html>

    Isso evita o aviso de hydration do Next.js
  5. 5

    Step 5: Crie o botão de troca de tema

    Use o hook useTheme:
    • Crie o componente ThemeToggle (marcado com 'use client')
    • Use useTheme() para obter theme e setTheme
    • Espere mounted antes de renderizar para evitar incompatibilidade de hydration

    Exemplo:
    const { theme, setTheme } = useTheme()
    const [mounted, setMounted] = useState(false)

    useEffect(() => setMounted(true), [])
    if (!mounted) return null

    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
    Trocar tema
    </button>
  6. 6

    Step 6: Teste e valide

    Pontos a testar:
    • Teste a troca manual de tema (sem flashes)
    • Teste o acompanhamento do tema do sistema
    • Teste se o tema permanece após atualizar a página
    • Teste a consistência do tema entre páginas

    Checklist:
    • A página carrega sem flashes
    • A troca de tema é fluida
    • O localStorage armazena a opção corretamente
    • A troca do tema do sistema é acompanhada automaticamente

FAQ

Por que a página pisca durante o carregamento?
Durante a renderização no servidor (SSR), o servidor não conhece a preferência de tema do usuário e renderiza o tema padrão. O localStorage só pode ser lido durante a hydration no cliente, quando o tema é trocado e o flash aparece. next-themes injeta uma tag script que lê o tema antes da renderização e resolve esse problema.
Qual é a diferença entre next-themes e outras bibliotecas de temas?
next-themes foi criada especificamente para Next.js, resolve o flash em SSR, não tem dependências e ocupa pouco espaço (menos de 1 KB). use-dark-mode não foi projetada para Next.js e apresenta problemas de compatibilidade em SSR. theme-ui é poderosa, mas pesada demais para quem só precisa trocar entre os modos claro e escuro.
Como acompanhar o tema do sistema?
Defina enableSystem={true} no ThemeProvider. next-themes detectará e aplicará automaticamente a preferência de tema do sistema. O usuário também pode selecionar um tema manualmente, e essa escolha terá prioridade sobre o sistema. Há três modos: light, dark e system.
Por que suppressHydrationWarning é necessário?
Na renderização no servidor, a preferência do usuário não está disponível e o tema padrão é usado. Durante a hydration no cliente, o localStorage pode mudar o tema para aquele escolhido pelo usuário, deixando o HTML do servidor diferente do HTML do cliente. suppressHydrationWarning informa ao React que essa diferença é esperada e evita o aviso.
Por que o botão de troca de tema só deve ser renderizado depois de mounted?
Para evitar incompatibilidade de hydration. O servidor não conhece o tema armazenado no localStorage. Se o botão for renderizado imediatamente, o HTML do servidor poderá ser diferente do HTML do cliente, causando um erro de hydration no React. Esperar mounted garante que ele seja renderizado apenas no cliente.
Como personalizar a lógica de troca de tema?
Use o método setTheme do hook useTheme. Por exemplo: setTheme(theme === 'dark' ? 'light' : 'dark'). Também é possível definir diretamente um tema específico com setTheme('dark'), setTheme('light') ou setTheme('system').
Quais temas next-themes aceita?
Por padrão, há suporte aos temas light e dark. Você também pode definir outros temas pela propriedade themes do ThemeProvider, por exemplo: themes={['light', 'dark', 'blue', 'green']}. Cada tema corresponde a um nome de classe CSS diferente.

11 min de leitura · Publicado em: 20 dez 2025 · Atualizado em: 4 set 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog