Go back to posts

A Better Way to handle themes between client and server

A Better Way to handle themes between client and server

TL;DR

Learn how to prevent hydration mismatches when implementing themes in SSR/SSG applications. Explore script-based approaches for static generation to ensure consistent theme rendering between server and client.

What is the problem?

When building a web application, you often need to handle themes like light and dark mode. if you are using React or Vue.js like client-side frameworks or libraries, there’s no problem. but when you are using server-side tech like SSR or SSG, you can face mismatch issues between the server-rendered HTML and the client-side JavaScript. that problem is called “hydration mismatch”.

Why does it happen?

This happens because when the server renders the HTML, it may not know the user’s theme. when the client-side JavaScript takes over, it may render a different theme compared to what the server rendered.

How to solve it?

Usually, you can solve this problem by using cookies to store the user’s theme. but when you are using SSG, you can’t access cookies in the runtime, so you need to use a different approach.

From next-theme, you can see that they use <script> to solve this problem. If you look at the code, they check on the client side if the theme is set in localStorage or prefers-color-scheme, and if not, they set it to the default theme. By adding <script> to the top of <head>, you can ensure that tasks like setting the theme are done before hydration.

// src/index.tsx(187 ~ 220)
export const ThemeScript = React.memo(
  ({
    forcedTheme,
    storageKey,
    attribute,
    enableSystem,
    enableColorScheme,
    defaultTheme,
    value,
    themes,
    nonce,
    scriptProps,
  }: Omit<ThemeProviderProps, 'children'> & { defaultTheme: string }) => {
    const scriptArgs = JSON.stringify([
      attribute,
      storageKey,
      defaultTheme,
      forcedTheme,
      themes,
      value,
      enableSystem,
      enableColorScheme,
    ]).slice(1, -1);

    return (
      <script
        {...scriptProps}
        suppressHydrationWarning
        nonce={typeof window === 'undefined' ? nonce : ''}
        dangerouslySetInnerHTML={{
          __html: `(${script.toString()})(${scriptArgs})`,
        }}
      />
    );
  },
);

// src/script.ts
export const script = (
  attribute,
  storageKey,
  defaultTheme,
  forcedTheme,
  themes,
  value,
  enableSystem,
  enableColorScheme,
) => {
  const el = document.documentElement;
  const systemThemes = ['light', 'dark'];

  function updateDOM(theme: string) {
    const attributes = Array.isArray(attribute) ? attribute : [attribute];

    attributes.forEach((attr) => {
      const isClass = attr === 'class';
      const classes =
        isClass && value ? themes.map((t) => value[t] || t) : themes;
      if (isClass) {
        el.classList.remove(...classes);
        el.classList.add(value && value[theme] ? value[theme] : theme);
      } else {
        el.setAttribute(attr, theme);
      }
    });

    setColorScheme(theme);
  }

  function setColorScheme(theme: string) {
    if (enableColorScheme && systemThemes.includes(theme)) {
      el.style.colorScheme = theme;
    }
  }

  function getSystemTheme() {
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light';
  }

  if (forcedTheme) {
    updateDOM(forcedTheme);
  } else {
    try {
      const themeName = localStorage.getItem(storageKey) || defaultTheme;
      const isSystem = enableSystem && themeName === 'system';
      const theme = isSystem ? getSystemTheme() : themeName;
      updateDOM(theme);
    } catch (e) {
      //
    }
  }
};
Note

You can use suppressHydrationWarning to avoid hydration mismatch warnings in the console. but it’s not solving the problem, just hiding the warning. and you might need to disableAnimations to avoid flickering when the theme is set. check out next-theme .

const disableAnimation = (nonce?: string) => {
  const css = document.createElement('style')
  if (nonce) css.setAttribute('nonce', nonce)
  css.appendChild(
    document.createTextNode(
      `*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}`
    )
  )
  document.head.appendChild(css)

  return () => {
    // Force restyle
    ; (() => window.getComputedStyle(document.body))()

    // Wait for next tick before removing
    setTimeout(() => {
      document.head.removeChild(css)
    }, 1)
  }
}