Next.js로 사이트 만들 때 다크 모드 붙이는 게 생각보다 까다롭거든요. 단순히 클래스 토글만 하면 새로고침할 때 화면이 깜빡거리는 FOUC(Flash of Unstyled Content) 문제가 생기고, SSR 환경에서는 hydration mismatch 에러까지 따라와요.
이 글에서는 Next.js App Router 기준으로 next-themes 라이브러리를 써서 라이트/다크/시스템 테마를 깔끔하게 구현하는 방법을 정리해볼게요.
왜 next-themes를 쓰나
직접 만들 수도 있긴 한데, 다크 모드를 제대로 구현하려면 신경 써야 할 게 꽤 많아요.
- 첫 로드 시 화면 깜빡임 방지 (FOUC)
- SSR과 클라이언트 상태 동기화 (hydration mismatch 방지)
- 시스템 테마(prefers-color-scheme) 감지
- localStorage 저장과 복원
- 여러 컴포넌트 사이의 테마 상태 공유

next-themes는 이 다섯 가지를 다 해결해줘요. 주간 다운로드 수도 수백만 단위라 사실상 표준이거든요. 직접 구현하는 거보다 시간 아끼면서 더 안정적이에요.
설치
App Router 기준이에요. Pages Router도 거의 동일하지만 import 경로만 살짝 다르거든요.
npm install next-themes
이게 끝이에요. 패키지가 작아서 번들 사이즈 부담도 거의 없구요.
ThemeProvider 설정
next-themes 자체가 클라이언트 컴포넌트라서, App Router에서는 'use client' 컴포넌트로 한 번 감싸서 써야 해요.
// app/providers.tsx
'use client'
import { ThemeProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
)
}
그리고 루트 레이아웃에서 적용해요.
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
여기서 핵심은 <html> 태그의 suppressHydrationWarning이에요. 빼먹으면 React가 "서버랑 클라이언트 마크업이 다르다"고 콘솔에 빨간 경고를 띄우거든요. 정상 동작인데도 시끄럽게 떠서, 빼먹으면 디버깅할 때 헷갈리기 쉬워요.
옵션 정리해두면 이래요.
attribute="class": html 태그에dark클래스 추가/제거 방식defaultTheme="system": 첫 방문 시 OS 설정 따라감enableSystem: 시스템 테마 옵션 활성화disableTransitionOnChange: 테마 전환 시 transition 끔(깜빡임 방지)
Tailwind CSS 연동
Tailwind v3 기준으로 tailwind.config.ts에서 다크 모드를 class 방식으로 설정해야 next-themes랑 맞물려요.
// tailwind.config.ts
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: 'class',
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
theme: { extend: {} },
plugins: [],
}
export default config
Tailwind v4를 쓴다면 CSS 파일에서 직접 선언해요.
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
이렇게 하고 나면 컴포넌트에서 dark: prefix를 자유롭게 쓸 수 있어요.
<div className="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
hello
</div>
CSS 변수로 색상 정의하기
dark: prefix를 매번 쓰는 게 귀찮으면 CSS 변수로 디자인 토큰을 만들어두는 게 훨씬 깔끔해요. 실전에서 가장 많이 쓰는 방식이거든요.
/* globals.css */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222 47% 11%;
--primary: 221 83% 53%;
}
.dark {
--background: 222 47% 11%;
--foreground: 210 40% 98%;
--primary: 217 91% 60%;
}
}
Tailwind config에 연결하면 클래스 한 번으로 양쪽 테마가 다 잡혀요.
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: 'hsl(var(--primary))',
},
},
},
그러면 bg-background text-foreground만 적어도 라이트/다크 둘 다 자동으로 적용돼요. shadcn/ui도 이 방식 그대로 쓰고 있구요.
토글 버튼 만들기
가장 헷갈리는 부분이 이거예요. 서버에서 렌더링될 때는 어떤 테마인지 알 수 없으니까, theme 값을 그대로 렌더링하면 hydration mismatch가 나거든요.
해결법은 mounted 상태로 클라이언트에서만 렌더링하는 거예요.
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) {
return <div className="h-9 w-9" />
}
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="rounded-md p-2"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
)
}
!mounted 상태에서 같은 크기의 placeholder를 반환하는 게 포인트예요. 그래야 토글 버튼이 나타나는 순간 레이아웃이 튀지 않거든요.
라이트/다크/시스템 세 가지 옵션을 모두 노출하려면 드롭다운으로 만들고 setTheme('system')까지 호출하면 돼요.
자주 마주치는 트러블
실제로 개발하다 보면 만나는 문제들 정리해볼게요.
1) Hydration mismatch 경고가 계속 떠요
<html> 태그에 suppressHydrationWarning을 꼭 붙여야 해요. next-themes 공식 문서에도 명시되어 있는데 빼먹기 쉽거든요. 다른 태그에는 안 붙여도 돼요.
2) 새로고침할 때 흰 화면이 잠깐 보여요
next-themes가 <head> 안에 인라인 스크립트를 자동으로 넣어서 hydration 전에 테마를 결정해줘요. 그래도 깜빡인다면 defaultTheme이랑 enableSystem 설정이 어긋났는지, body 배경색이 CSS 변수로 잘 잡혀 있는지 확인해보면 돼요.
3) 테마 전환할 때 색이 천천히 바뀌어요
disableTransitionOnChange 옵션을 켜면 전환 시 transition을 잠깐 꺼서 깔끔하게 바뀌어요. 안 켜면 모든 색상이 fade로 바뀌면서 어색하게 보이거든요.
4) 이미지나 SVG도 다크 모드에 맞춰야 할 때
로고 같은 거는 dark:hidden과 hidden dark:block 두 개를 같이 쓰는 게 가장 간단해요. SVG는 currentColor를 쓰면 텍스트 색상 따라 자동으로 바뀌구요.
5) 외부 라이브러리(차트, 에디터)가 다크 모드를 안 따라가요
이건 useTheme의 resolvedTheme을 prop으로 넘겨서 라이브러리에 직접 알려줘야 해요. theme은 'system'을 그대로 반환하니까, 실제 적용된 값이 필요하면 resolvedTheme이 정확해요.
const { resolvedTheme } = useTheme()
// resolvedTheme: 'light' 또는 'dark' (system이어도 실제 값 반환)
정리
Next.js에서 다크 모드는 next-themes + Tailwind class 방식 + CSS 변수 디자인 토큰 조합이 가장 깔끔해요. 직접 만들지 말고 next-themes의 suppressHydrationWarning, mounted 패턴, disableTransitionOnChange 세 가지만 챙기면 깜빡임 없이 잘 돌아갑니다.
shadcn/ui나 Radix 같은 라이브러리도 같은 패턴이라, 이 구조를 익혀두면 새 프로젝트 시작할 때마다 30분 안에 다크 모드를 붙일 수 있어요.

'개발 > Frontend' 카테고리의 다른 글
| shadcn/ui 커스텀 테마 만들기 CSS 변수 가이드 (0) | 2026.06.09 |
|---|---|
| useTransition vs 디바운스 차이와 실전 활용 (0) | 2026.06.07 |
| pnpm vs yarn 차이와 선택 기준 (0) | 2026.06.06 |
| React Hook Form Zod 폼 검증 실전 패턴 (0) | 2026.06.05 |