skip to Main Content

Like some many other answers stated, e.g. this one, Next.js runs both on the client as well as on the server, so you need a guard to be able to properly fetch from localStorage:

if (typeof localStorage !== "undefined") {
  return localStorage.getItem("theme")
} else {
  return "light"
}

That’s what I’m doing as well, and, since I’m using DaisyUI and having my theme specified on <html data-theme={theme}>, I’m also basically wrapping my whole app with a theme provider.

This has yielded me 2 problems:

  • The app mounts with the default theme for a very brief moment, then identifies what’s in localStorage, and goes into the saved, expected theme.
  • The icon button carrying the respective icon for the theme does not sync properly initially.

If I take off the the guard, then everything actually works as expected, and none of the problems mentioned above, but then I get this error on the server:

⨯ src/lib/context/ThemeContext.tsx (37:10) @ getPreference
 ⨯ ReferenceError: localStorage is not defined
    at getPreference
    at ThemeProvider
  36 |       const storedPreference = localStorage.getItem("theme")
> 37 |       return storedPreference
     |          ^
  38 |         ? stringToTheme(storedPreference)
  39 |         : "light"

I think this means I might need to disable SSR for the whole app somehow. Is this the way to go? Or is there another way to go? Maybe there’s a way of disabling SSR only for this somehow?

I’ve tried something like this, and it does work, though I don’t know it’s ideal, after all it disables one of the biggest benefits of Next.js itself:

const DynamicApp = dynamic(
  () =>
    import("./dapp").then((mod) => mod.ThemedAndAuthedApp),
  {
    loading: () => (
      <html>
        <body>
          <p>Loading...</p>
        </body>
      </html>
    ),
    ssr: false,
  }
)

I do get this error though:

Uncaught Error: Hydration failed because the initial UI does not match what was rendered on the server.

2

Answers


  1. Chosen as BEST ANSWER

    As suggested by @amirseify and @Zulzidan.com, the standard way of solving this is through cookies. The local storage is for the client only, so you cannot do SSR from it. However, cookies are sent to the server with requests, and are altered by the server itself on the client.

    And Next.js comes with a handy cookies API already. I'm also using Shad UI, and it does point out that there is a next-themes package we could use for this case, but I wasn't able to do it. Instead, I went with my custom theme context.

    Given all that, here's a solution I was able to come up with:

    1. Get the theme cookie on the RootLayout:

      export default function RootLayout({
        children,
      }: WithReactChildren) {
        const cookieStore = cookies()
        const theme = cookieStore.get("theme")
          ? cookieStore.get("theme")!.value
          : Theme.light
      
        return (
          <ThemeProvider initialTheme={theme}>
            <App>{children}</App>
          </ThemeProvider>
        )
      }
      
    2. Get the themed app into another file. Since we're going to use the theme context, we need to use "use client" — this might defeat Next.js's SSR purpose, but it's the best I could, since the whole app needs the theme after all... —:

      "use client"
      
      ...
      
      export function App({ children }: WithReactChildren) {
        const { theme } = useTheme()
      
        return (
          <html lang="en">
            <body
              className={cn(
                "min-h-screen bg-background font-sans antialiased",
                inter.variable,
                theme,
              )}
            >
              <TopNav />
              <main>{children}</main>
            </body>
          </html>
        )
      }
      
    3. Create a server action for saving the theme into a cookie:

      "use server"
      
      import { cookies } from "next/headers"
      
      import { type Theme } from "@types"
      
      export async function saveTheme(theme: Theme) {
        try {
          const cookieStore = cookies()
          cookieStore.set("theme", theme)
        } catch (e) {
          console.error(e)
        }
      }
      
    4. Create a button for cycling the theme through the theme context and saving it on a cookie through the server action:

      "use client"
      
      ...
      
      export function ThemeButton() {
        const { cycleTheme } = useTheme()
      
        return (
          <Button
            variant="outline"
            size="icon"
            onClick={async () => {
              const nextTheme = cycleTheme()
              await saveTheme(nextTheme)
            }}
          >
            <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
            <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          </Button>
        )
      }
      

  2. It’s better to manage the theme preference effectively while maintaining server-side rendering (SSR) capabilities. Because that’s the core usage of NextJs

    First initialize the theme state in the context provider with a default theme to avoid visible theme switch on client load

     // Initialize theme state with a safe default for SSR
      const [theme, setTheme] = useState<Theme>(Theme.retro);
    

    Then make sure getPreference function ensures that localStorage is accessed only client-side, preventing server-side errors.

    // Function to read theme from localStorage
      function getPreference(): Theme {
        if (typeof window !== "undefined" && localStorage.getItem("theme")) {
          return stringToTheme(localStorage.getItem("theme")!);
        }
        return theme; // Return current theme as fallback during SSR
      }
    

    Finally use useEffect hook to apply the theme after the component mounts, ensuring correct hydration and avoiding UI flicker.

    ThemeContext.tsx:

    import React, { createContext, useContext, useEffect, useState } from "react";
    
    
    export enum Theme {
      light = "light",
      retro = "retro",
      dark = "dark",
    }
    
    
    export function stringToTheme(s: string): Theme {
      return Object.values(Theme).find(t => t === s) || Theme.light;
    }
    
    
    type ThemeContextType = {
      theme: Theme;
      setTheme: React.Dispatch<React.SetStateAction<Theme>>;
      cycleTheme: () => void;
      syncTheme: () => void;
    };
    
    
    const ThemeContext = createContext<ThemeContextType | null>(null);
    
    export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
      
      const [theme, setTheme] = useState<Theme>(Theme.retro);
    
      
      function getPreference(): Theme {
        if (typeof window !== "undefined" && localStorage.getItem("theme")) {
          return stringToTheme(localStorage.getItem("theme")!);
        }
        return theme; 
      }
    
      
      function savePreference(theme: Theme): void {
        if (typeof window !== "undefined") {
          localStorage.setItem("theme", theme);
        }
      }
    
      
      function syncTheme(): void {
        const savedTheme = getPreference();
        setTheme(savedTheme);
        document.documentElement.dataset.theme = savedTheme;
      }
    
      
      function cycleTheme(): void {
        const themes = Object.values(Theme);
        const currentThemeIndex = themes.indexOf(theme);
        const nextTheme = themes[(currentThemeIndex + 1) % themes.length];
        savePreference(nextTheme);
        setTheme(nextTheme);
      }
    
      // Sync theme on client side after mount
      useEffect(() => {
        syncTheme();
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, []);
    
      return (
        <ThemeContext.Provider value={{ theme, setTheme, cycleTheme, syncTheme }}>
          {children}
        </ThemeContext.Provider>
      );
    };
    
    
    export function useTheme(): ThemeContextType {
      const context = useContext(ThemeContext);
      if (!context) {
        throw new Error("`useTheme` must be used within a `ThemeProvider`.");
      }
      return context;
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search