Next.js 다크 모드 구현: next-themes 완벽 가이드

처음 Next.js 프로젝트에서 다크 모드를 구현했을 때 꽤 크게 애를 먹었습니다. 페이지가 로드되는 순간 흰 화면이 번쩍인 뒤에야 다크 모드로 바뀌었습니다. 그 깜빡임은 정말 신경 쓰였습니다. 한 사용자가 댓글로 “눈이 멀 것 같다”고 불평한 뒤에야 문제가 얼마나 심각한지 깨달았습니다.
직접 구현해 보기도 하고 use-dark-mode 라이브러리도 써 봤으며 수많은 튜토리얼까지 찾아봤습니다. 결국 진짜 해결책은 next-themes였습니다. 지금은 모든 프로젝트에서 이 라이브러리를 사용합니다. 화면이 깜빡이지 않고 설정도 아주 간단하며 시스템 테마도 제대로 따라갑니다. 이 글에서는 제가 겪었던 문제와 찾아낸 해결 방법을 정리합니다.
결국 next-themes를 선택한 이유
처음에는 테마 전환 로직을 직접 만들지 고민했습니다. localStorage를 읽고 class만 바꾸면 되니 간단해 보였습니다. 하지만 실제로 구현해 보니 Next.js의 서버 사이드 렌더링 특성 때문에 생각보다 훨씬 복잡했습니다.
몇 가지 방법을 시도했습니다.
직접 구현: 가장 큰 문제는 깜빡임입니다. SSR 중에는 서버가 사용자의 테마 설정을 알 수 없어 기본 라이트 테마를 렌더링합니다. 클라이언트 hydration 단계에서야 localStorage를 읽을 수 있으므로, 이때 다크 테마로 전환하면 눈에 띄는 깜빡임이 생깁니다.
use-dark-mode: 괜찮은 라이브러리이지만 Next.js 전용으로 설계된 것은 아닙니다. SSR 환경에서는 여전히 호환성 문제가 생길 수 있습니다.
theme-ui: 기능은 강력하지만 다크 모드 전환만 필요한 상황에는 지나치게 무겁고 bundle size도 큽니다.
마지막으로 찾은 것이 next-themes입니다. GitHub Star가 6,000개 이상이고 Next.js 전용으로 설계되었으며 의존성이 없습니다. gzip 기준 크기도 1KB 미만입니다. 무엇보다 실제로 화면 깜빡임을 없애고, 별도 설정 없이 시스템 테마를 지원하며, 선택한 테마를 자동 저장합니다. TypeScript 지원도 충실해 편하게 사용할 수 있습니다.
전체 구현 단계
의존성 설치
먼저 패키지를 설치합니다.
npm install next-themes
pnpm이나 yarn을 사용해도 됩니다.
pnpm add next-themes
# 또는
yarn add next-themes
ThemeProvider 컴포넌트 만들기
다음으로 Provider 컴포넌트를 만듭니다. 저는 보통 프로젝트에 providers 또는 components 디렉터리를 만들어 이런 컴포넌트를 관리합니다.
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>
}
next-themes는 브라우저 API에 접근해야 하므로 반드시 'use client'를 표시해야 합니다. 제가 처음 겪은 문제도 이것이었습니다. 처음에 이 표시를 추가하지 않아 hydration 오류가 잇달아 발생했습니다.
Layout에 통합하기
이제 ThemeProvider를 루트 레이아웃에 추가합니다. App Router(Next.js 13+)를 사용한다면 app/layout.tsx 파일입니다.
import { ThemeProvider } from '@/providers/theme-provider'
import './globals.css'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
)
}
여기에는 몇 가지 중요한 설정이 있습니다. 하나씩 살펴보겠습니다.
attribute="class": <html> 요소의 class를 바꿔 테마를 전환하도록 next-themes에 지시합니다. Tailwind CSS의 dark: 접두사와 함께 사용하기 편합니다.
defaultTheme="system": 기본값으로 시스템 테마를 따릅니다. 사용자가 처음 방문하면 운영체제의 테마 설정을 자동으로 감지합니다.
enableSystem: 시스템 테마 감지 기능을 활성화합니다. 이 옵션을 켜지 않으면 defaultTheme="system"이 동작하지 않습니다.
disableTransitionOnChange: 테마를 바꿀 때 전환 애니메이션을 비활성화합니다. 필요에 따라 조정할 수 있지만, 켜 두는 것을 권합니다. 다크 모드 전환 시 애니메이션이 적용되면 모든 요소가 동시에 움직여 오히려 보기 좋지 않을 수 있습니다.
suppressHydrationWarning: <html> 태그에 추가하는 매우 중요한 속성입니다. next-themes는 클라이언트 hydration 전에 html 요소의 class를 변경합니다. 이 속성이 없으면 React가 경고를 표시합니다.
테마 전환 버튼 만들기
Provider를 추가했으니 이제 전환 버튼을 만들 수 있습니다. 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="테마 전환"
>
{theme === 'dark' ? '🌞' : '🌙'}
</button>
)
}
여기에는 작은 요령이 하나 있습니다. 컴포넌트가 마운트되기 전에는 null을 반환합니다. 왜 그럴까요? 서버 렌더링 중에는 테마 정보를 가져올 수 없어서 바로 렌더링하면 hydration mismatch가 발생하기 때문입니다. 클라이언트에서 mounted 상태가 된 뒤에야 useTheme가 현재 테마를 정확히 반환할 수 있습니다.
light / dark / system의 세 가지 상태를 순환하는 버튼을 만들고 싶다면 다음과 같이 작성할 수 있습니다.
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>
)
}
깜빡임 문제 자세히 살펴보기
제가 이 문제를 제대로 파고들게 된 계기도 바로 그 성가신 깜빡임이었습니다. 원리를 완전히 이해하기까지 꽤 많은 시간이 걸렸습니다.
FOUC가 발생하는 원리
FOUC(Flash of Unstyled Content)는 Next.js 다크 모드를 구현할 때 특히 자주 나타납니다. 근본 원인은 SSR 상태와 클라이언트 상태가 서로 다르다는 데 있습니다.
서버 렌더링 시 Node.js 환경에는 window 객체가 없습니다. localStorage에도 접근할 수 없고 사용자의 시스템 테마 설정도 알 수 없습니다. 따라서 서버는 기본 테마, 보통 라이트 테마를 렌더링할 수밖에 없습니다.
그런 다음 HTML이 브라우저로 전송되고 hydration이 시작됩니다. 이 과정에서 React는 서버가 렌더링한 정적 HTML을 상호작용 가능한 컴포넌트로 바꿉니다. 그제야 JavaScript가 localStorage를 읽고 사용자가 전에 다크 테마를 선택했다는 사실을 확인한 뒤 DOM을 수정해 dark class를 추가합니다.
이 변경으로 다시 렌더링이 일어나고 모든 스타일이 라이트 테마에서 다크 테마로 바뀝니다. 이것이 화면이 깜빡이는 이유입니다.
next-themes의 해결 방식
next-themes의 해결 방식은 영리합니다. <head>에 blocking script를 삽입합니다. 이 script는 페이지를 렌더링하기 전에 실행되어 localStorage의 테마 설정을 즉시 읽고 <html> 요소에 알맞은 class를 추가합니다.
대략 다음과 같은 로직입니다.
(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) {}
})()
이 script는 동기 방식으로 실행되어 페이지 렌더링을 차단합니다. 따라서 콘텐츠가 표시되기 전에 올바른 테마 class가 설정됩니다. CSS가 처음부터 정확한 스타일을 적용하므로 화면이 깜빡이지 않습니다.
자주 발생하는 설정 오류
설정 문제를 겪는 경우는 주로 다음 항목에 집중됩니다.
suppressHydrationWarning 누락:
<html> 태그에 이 속성을 추가하지 않으면 콘솔에 다음 경고가 계속 나타납니다.
Warning: Prop `className` did not match. Server: "" Client: "dark"
기능에는 영향을 주지 않지만 계속 보이면 신경 쓰입니다.
ThemeProvider 위치 오류:
ThemeProvider를 Server Component 안이나 body 밖에 배치하면 문제가 발생합니다. ThemeProvider는 페이지 콘텐츠를 감싸야 하고 반드시 Client Component여야 합니다.
잘못된 Tailwind 설정:
tailwind.config.js가 다음과 같이 설정되어 있다면 문제가 생깁니다.
module.exports = {
darkMode: 'media',
}
media 모드는 CSS만 사용하는 방식이라 시스템 테마만 따를 수 있고 수동 전환은 할 수 없습니다. 다음과 같이 바꿔야 합니다.
module.exports = {
darkMode: 'class',
}
테마 저장과 시스템 설정 연동
저장 방식
next-themes는 기본적으로 사용자가 선택한 테마를 localStorage에 저장하며 key는 'theme'입니다. 이 동작은 자동이므로 별도 코드를 작성할 필요가 없습니다.
storage key를 직접 지정하려면 다음과 같이 설정합니다.
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
storageKey="my-theme"
>
{children}
</ThemeProvider>
어떤 상황에서는 localStorage 대신 Cookie를 사용해야 할 수 있습니다. 서버에서부터 사용자의 테마 설정을 파악해 발생할 수 있는 모든 깜빡임을 피하고 싶을 때가 그렇습니다. 이 경우 다음과 같이 처리할 수 있습니다.
- 미들웨어에서 Cookie를 읽어 응답 헤더에 설정합니다.
- 서버 렌더링 시 응답 헤더에 따라 해당 테마를 렌더링합니다.
- 클라이언트에서 Cookie와 localStorage를 동기화합니다.
하지만 대부분의 상황에서는 next-themes의 기본 방식만으로 충분합니다.
시스템 테마 연동
enableSystem 옵션을 사용하면 next-themes가 시스템 테마 변경을 감지할 수 있습니다. 사용자가 운영체제 설정에서 다크 모드와 라이트 모드를 바꿀 때 애플리케이션의 현재 테마가 system이면 자동으로 전환됩니다.
내부적으로는 prefers-color-scheme 미디어 쿼리를 감지합니다.
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
// 테마 전환 로직
})
사용자는 시스템 테마를 직접 덮어쓸 수도 있습니다. 예를 들어 시스템이 라이트 모드여도 웹사이트에서 다크 모드로 바꾸면 next-themes가 이 선택을 기억하고 다음 방문에도 다크 모드를 적용합니다.
여러 테마 지원
이 글에서는 주로 다크 모드를 다루지만 next-themes는 여러 테마를 자유롭게 지원합니다. 예를 들어 보라색이나 초록색 테마를 만들 수 있습니다.
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
themes={['light', 'dark', 'purple', 'green']}
>
{children}
</ThemeProvider>
그런 다음 CSS에 해당 스타일을 정의합니다.
.purple {
--background: #f3e8ff;
--foreground: #581c87;
}
.green {
--background: #dcfce7;
--foreground: #14532d;
}
CSS 변수와 함께 사용하면 매우 유연합니다.
실전 팁과 자주 묻는 문제
Tailwind CSS와 함께 사용하기
Tailwind를 사용한다면 설정은 더 간단합니다. 먼저 tailwind.config.js에 다음 항목이 있는지 확인합니다.
module.exports = {
darkMode: 'class',
// 기타 설정...
}
이제 dark: 접두사를 자유롭게 사용할 수 있습니다.
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<h1 className="text-2xl font-bold">제목</h1>
<p className="text-gray-600 dark:text-gray-400">단락 텍스트</p>
</div>
Tailwind의 dark: 변형은 <html> 요소에 dark class가 있을 때 적용됩니다. next-themes의 동작 방식과 정확히 맞습니다.
애니메이션과 전환 효과
disableTransitionOnChange 옵션은 켜 두는 것을 권합니다. CSS에 transition 속성이 많으면 테마를 전환할 때 모든 요소가 동시에 애니메이션되어 화면이 다소 산만해 보일 수 있기 때문입니다.
그래도 전환 효과가 필요하다면 다음과 같이 설정합니다.
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange={false}
>
{children}
</ThemeProvider>
전역 CSS에는 다음 코드를 추가합니다.
* {
transition: background-color 0.2s ease, color 0.2s ease;
}
이렇게 하면 테마를 바꿀 때 페이드 인·아웃 효과가 나타납니다. 하지만 여러 번 사용해 본 결과 전환 효과가 없는 쪽이 더 깔끔하고 즉각적으로 느껴졌습니다.
TypeScript 타입 지원
next-themes는 TypeScript를 잘 지원합니다. 테마 타입을 확장하고 싶다면 다음과 같이 작성할 수 있습니다.
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),
}
}
이렇게 하면 사용할 때 타입 힌트가 제공되어 존재하지 않는 테마를 잘못 지정하는 일을 막을 수 있습니다.
자주 발생하는 문제 해결
문제 1: 테마를 전환했는데 스타일이 바뀌지 않음
다음 항목을 확인합니다.
- Tailwind의
darkMode설정이'class'인지 - CSS에서
dark:접두사 또는.dark선택자를 올바르게 사용했는지 - 브라우저 콘솔에서
<html>요소에 class가 제대로 추가되었는지
문제 2: 페이지를 새로고침해도 잠깐 깜빡임
여전히 화면이 깜빡인다면 다음과 같은 원인일 수 있습니다.
<html>에suppressHydrationWarning을 추가하지 않음- ThemeProvider의 위치가 잘못됨
- Google Analytics 같은 다른 script가 간섭함
문제 3: 시스템 테마 연동이 동작하지 않음
다음을 확인합니다.
enableSystem이true로 설정되어 있는지- 브라우저가
prefers-color-scheme을 지원하는지(현대적인 브라우저는 모두 지원합니다.) - 현재 테마가
system인지(수동으로 전환했다면light또는dark일 수 있습니다.)
마무리
처음에는 화면 깜빡임 때문에 고생했지만 지금은 next-themes 덕분에 다크 모드를 매끄럽게 구현할 수 있습니다. 이 라이브러리는 기술적인 문제를 해결할 뿐 아니라 사용자 경험까지 개선합니다.
핵심 내용을 다시 정리하면 다음과 같습니다.
next-themes를 사용하면 Next.js 다크 모드의 깜빡임 문제를 별도 설정 없이 해결할 수 있습니다.<html>에suppressHydrationWarning을 추가하고 ThemeProvider를 클라이언트 컴포넌트로 표시해야 합니다.- Tailwind의
darkMode설정을'class'로 지정합니다. - hydration 불일치를 피하려면 테마 전환 버튼을 mounted 이후에 렌더링합니다.
- 시스템 테마 연동과 수동 전환을 함께 사용할 수 있습니다.
프로젝트에서 next-themes를 아직 사용하지 않았다면 한번 적용해 보세요. 공식 문서도 잘 정리되어 있습니다: github.com/pacocoursey/next-themes
이제 Next.js 프로젝트에 매끄러운 다크 모드를 추가해 보세요. 사용자도 그 차이를 느낄 수 있을 것입니다.
Next.js 다크 모드 구현 전체 과정
next-themes로 깜빡임 없는 다크 모드를 구현하고 시스템 테마 연동과 수동 전환을 지원합니다.
⏱️ Estimated time: 30 min
- 1
Step 1: next-themes 설치
의존성을 설치합니다.
• npm install next-themes
다른 패키지 관리자를 사용할 수도 있습니다.
• pnpm add next-themes
• yarn add next-themes
참고: next-themes는 의존성이 없고 용량이 매우 작습니다. - 2
Step 2: ThemeProvider 설정
루트 레이아웃에 추가합니다.
• providers.tsx 파일을 만들고 'use client'를 표시합니다.
• ThemeProvider로 children을 감쌉니다.
• app/layout.tsx에서 가져와 사용합니다.
핵심 설정:
• attribute="class": class로 테마를 전환합니다.
• enableSystem: 시스템 테마 연동을 활성화합니다.
• storageKey: localStorage 저장 키 이름입니다.
참고: ThemeProvider는 반드시 클라이언트 컴포넌트여야 합니다. - 3
Step 3: Tailwind CSS 설정
tailwind.config.js에서 다음과 같이 설정합니다.
• darkMode: 'class'로 지정합니다.
• 그러면 Tailwind가 html 태그의 class에 따라 테마를 전환합니다.
설정 예시:
module.exports = {
darkMode: 'class',
// ... 기타 설정
}
dark: 접두사로 다크 모드 스타일을 정의합니다.
className="bg-white dark:bg-gray-900" - 4
Step 4: hydration 경고 수정
html 태그에 다음을 추가합니다.
• suppressHydrationWarning 속성
• 서버와 클라이언트의 테마가 달라 발생하는 경고를 방지합니다.
layout.tsx에서:
<html lang="zh" suppressHydrationWarning>
<body>{children}</body>
</html>
이렇게 하면 Next.js의 hydration 경고를 피할 수 있습니다. - 5
Step 5: 테마 전환 버튼 만들기
useTheme hook을 사용합니다.
• ThemeToggle 컴포넌트를 만들고 'use client'를 표시합니다.
• useTheme()으로 theme과 setTheme을 가져옵니다.
• hydration 불일치를 피하려면 mounted 이후에 렌더링합니다.
예시:
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
테마 전환
</button> - 6
Step 6: 테스트 및 검증
테스트 항목:
• 수동 테마 전환 시 깜빡임이 없는지 확인
• 시스템 테마 연동 확인
• 새로고침 후에도 테마가 유지되는지 확인
• 페이지가 달라도 테마가 일관적인지 확인
체크리스트:
• 페이지 로드 시 깜빡임 없음
• 부드러운 테마 전환
• localStorage에 올바르게 저장
• 시스템 테마 변경을 자동으로 반영
FAQ
페이지를 불러올 때 왜 화면이 깜빡이나요?
next-themes는 다른 테마 라이브러리와 무엇이 다른가요?
시스템 테마를 따르게 하려면 어떻게 하나요?
suppressHydrationWarning이 왜 필요한가요?
테마 전환 버튼을 mounted 이후에 렌더링해야 하는 이유는 무엇인가요?
테마 전환 로직을 직접 구성하려면 어떻게 하나요?
next-themes는 어떤 테마를 지원하나요?
3분 읽기 · 게시일: 2025년 12월 20일 · 수정일: 2026년 9월 4일
Next.js 완전 가이드
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Vercel을 떠나 Next.js를 Docker로 셀프 호스팅하는 방법
Next.js를 Docker로 셀프 호스팅하는 과정을 설명합니다. standalone 설정, 다단계 Dockerfile, Nginx 리버스 프록시, 스트리밍 렌더링 문제 해결과 비용 비교까지 다룹니다.
45편 중 38편
다음
Next.js + Tailwind CSS 모범 사례: 설정부터 다크 모드까지 완벽 가이드(2025년판)
2025년 최신 Next.js + Tailwind CSS v4 실전 가이드입니다. 지나치게 긴 클래스명, 사용자 정의 테마 설정, 다크 모드 구현, 성능 최적화 문제를 해결하고 CSS를 500KB에서 50KB로 줄인 실제 사례와 전체 코드 예제를 소개합니다.
45편 중 40편



댓글
GitHub로 로그인하여 댓글을 남기세요