1. 먼저 알아두기

시스템 세팅을 통해 구현하는 방법

CSS 미디어 쿼리의 @media (prefers-color-scheme: dark)를 사용하는 것으로 가장 간단히 구현할 수 있습니다.

See the Pen dark-mode-1 by _______eungook (@_______eungook) on CodePen.

버튼을 통해 구현하는 방법

CSS의 클래스를 통해 간단히 구현할 수 있고, 초기값은 Window.matchMedia를 통해 확인할 수 있습니다.

See the Pen dark-mode-2 by _______eungook (@_______eungook) on CodePen.

2. 깜박임 없이 다크모드 적용하기

여기서부터 조금 신경을 써야 합니다.
위에서 확인했듯, 시스템 세팅이 아닌 버튼을 통해 다크모드를 적용하려면 최초 스크립트의 실행이 필요합니다.

이 때 스크립트의 실행이 느리면 화면에서 깜박임을 사용자가 느낄 수 있습니다.
먼저 말하자면 useEffect에서 호출하는 스크립트는 정말 느립니다!

스크립트 실행 속도 비교

Next.js, SSR 기준, 스크립트 실행 속도를 비교해보면 다음과 같습니다.

  1. <script>
  2. next/script, <Script strategy="beforeInteractive">
  3. next/script, <Script strategy="afterInteractive">
  4. 클라이언트 컴포넌트, useEffect
  5. next/script, <Script strategy="lazyOnload">

즉 일반 <script>로 구현하는 것이 가장 실행이 빠릅니다. (약간 당연한 말이네요);;
코드로 구현해보면 다음과 같습니다.

// Next.js, layout.tsx
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en" suppressHydrationWarning={true}>
      <body>
        <script>{`
          (() => {
          	const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
          	if (isDark) {
	            document.documentElement.classList.add('dark');
          	}
          )();
        `}</script>
        {children}
      </body>
    </html>
  );
}

주의해야 할 점

먼저 <html>suppressHydrationWarning={true}가 필요합니다.
Element.classList.add를 하는 과정에서 React의 hydration mismatch가 발생할 수 밖에 없기 때문입니다.

그리고 <script>의 내용은 템플릿 리터럴로 작성해줘야 의도대로 동작합니다.
템플릿 리터럴 없이 작성할 경우 태그의 내용이 JSX의 문법으로 동작하게 되어
줄내림이 표현되지 않아 주석(//) 이후 내용이 실행되지 않거나,
중괄호({})가 의도와 달리 해석되는 등의 에러가 발생합니다.

실행속도 비교하는 방법

구글 크롬 개발자 도구의 퍼포먼스에 있는 녹화 기능을 통해 직접 확인해볼 수 있습니다.
확인하기 쉽도록 CPU 쓰로틀링을 x4 정도 걸고 녹화하는 것을 추천합니다.

2025-11-05-1-dark-mode-fast

<script>, DCL(=DOMContentLoaded)에서 적용된 경우.. 약 0.3초

2025-11-05-1-dark-mode-slow

<Script strategy="lazyOnload" />, L(=Window.onload)에서 적용된 경우.. 약 1초

3. notFound() 문제

사실 이게 까다롭습니다.

Next.js의 서버가 웹페이지에 대한 요청에 404 Not Found로 응답하는 경우는 두 가지가 있고,
이에 따라 페이지가 렌더되는 방식이 다릅니다.
여기서 이슈가 발생합니다.

3-1. 라우팅에 의한 404

Next.js의 라우팅에 해당하는 페이지가 없을 경우, 404와 함께 not-found.js가 렌더됩니다.
이 때 페이지는 SSR로 렌더되며, 따라서 2의 스크립트가 정상적으로 실행됩니다.

3-2. notFound()에 의한 동적인 404

하지만 next/navigation의 notFound()에서는 2의 스크립트가 정상 동작하지 않습니다.
이 때 페이지는 Hydration을 통해 CSR로 렌더되며, 따라서 <script> 태그의 내용이 실행되지 않습니다.

그래서 스크립트의 실행을 위해서는 next/script<Script strategy="afterInteractive">가 필요합니다.
여기서는 일반적인 Hydration과 달리 Node.appendChild()호출하기 때문에

// vercel/next.js, packages/next/src/client/script.tsx

function Script(props: ScriptProps): JSX.Element | null {
  // 전략
  if (strategy === 'afterInteractive') {
    loadScript(props)
  }
  // 후략
}

const loadScript = (props: ScriptProps): void => {
  // 전략
  document.body.appendChild(el)
}

<script>가 렌더되면서, 동시에 실행도 보장이 됩니다. (조금 느리지만요.) 다행이네요!

최종 코드

// Next.js, layout.tsx
import Script from "next/script";

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en" suppressHydrationWarning={true}>
      <body>
        <script src="/dark-mode.js" />
        <Script src="/dark-mode.js" strategy="afterInteractive" />
        {children}
      </body>
    </html>
  );
}

// Next.js, /public/dark-mode.js
(() => {
  const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  if (isDark) {
    document.documentElement.classList.add('dark');
  }
})();

참고

next/script의 배경에 대해서는 아래의 PR을 확인해주세요.

Script loader component by janicklas-ralph · Pull Request #18281 · vercel/next.js

CSR에서 <script>의 내용이 실행되지 않는 이유와,
next/script의 <Script>에서는 내용이 실행돠는 이유에 대해서는 아래의 HTML Standard를 확인해주세요.

HTML Standard: 4.12.1.1 Processing model
즉 insertedNode 과정을 거쳐야지만 <script>의 내용이 실행됩니다.