last updated: March 17, 2024
8 minute read
Implementing Dark Mode: A Server-Side Approach with Cookies
Last article, we talked about a useDarkMode
hook designed
for client-only apps based on the local storage and media query APIs. Unfortunately, the approach
didn't scale with server-rendered content and introduced the potential for a flash between a light
theme rendered on the server and a dark theme re-rendered on the client. So for apps that initially
render their content on the server, how can we approach implementing a dark mode? The key is to
shift away from client-only APIs like local storage and media queries to server-and-client APIs like
cookies.
tl;dr
// pages/_app.tsximport { createContext, Dispatch, SetStateAction, useContext, useEffect, useState } from "react";import App, { AppContext, AppInitialProps, AppProps } from "next/app";import * as cookie from "cookie";const ONE_YEAR = 60 * 60 * 24 * 365;interface Theme {isDarkMode: boolean;setIsDarkMode: null | Dispatch<SetStateAction<boolean>>;}export const ThemeContext = createContext<Theme>({isDarkMode: false,setIsDarkMode: null,});export function useDarkMode() {const { isDarkMode, setIsDarkMode } = useContext(ThemeContext);return [isDarkMode, setIsDarkMode as Dispatch<SetStateAction<boolean>>] as const;}type AppOwnProps = {isDarkModeCookie: boolean;};export default function MyApp({ Component, pageProps, isDarkModeCookie }: AppProps & AppOwnProps) {const [isDarkMode, setIsDarkMode] = useState(isDarkModeCookie);useEffect(() => {document.cookie = cookie.serialize("isDarkMode", String(isDarkMode), {httpOnly: false,maxAge: ONE_YEAR,path: "/",});}, [isDarkMode]);return (<ThemeContext.Provider value={{ isDarkMode, setIsDarkMode }}><Component {...pageProps} /></ThemeContext.Provider>);}MyApp.getInitialProps = async (context: AppContext): Promise<AppOwnProps & AppInitialProps> => {const pageProps = await App.getInitialProps(context);const { req, res } = context.ctx;if (!req || !res) {return {...pageProps,isDarkModeCookie: false,};}const isDarkModeCookie = cookie.parse(req.headers.cookie || "").isDarkMode as string | undefined;if (isDarkModeCookie === undefined) {res.setHeader("Set-Cookie",cookie.serialize("isDarkMode", String(false), {httpOnly: false,maxAge: ONE_YEAR,path: "/",}),);return {...pageProps,isDarkModeCookie: false,};}return {...pageProps,isDarkModeCookie: isDarkModeCookie === "true",};};
Setting up Context
First, as a bit of setup, we'll need to create a context so we can use isDarkMode
and
setIsDarkMode
globally without passing the two through props.
import { Dispatch, SetStateAction, createContext, useContext } from "react";interface Theme {isDarkMode: boolean;setIsDarkMode: null | Dispatch<SetStateAction<boolean>>;}export const ThemeContext = createContext<Theme>({isDarkMode: false,setIsDarkMode: null,});export function useDarkMode() {const { isDarkMode, setIsDarkMode } = useContext(ThemeContext);return [isDarkMode, setIsDarkMode as Dispatch<SetStateAction<boolean>>] as const;}
useDarkMode
is a bit more ergonomic than useContext
, and it also asserts that setIsDarkMode
isn't null
- which in practice, it never will be.
While we could wrap every page in the pages
directory with a <ThemeContext.Provider />
, it's
more ergonomic to write a custom pages/_app.tsx
to handle this for us.
// pages/_app.tsximport { createContext, Dispatch, SetStateAction, useContext, useState } from "react";import { AppProps } from "next/app";interface Theme {isDarkMode: boolean;setIsDarkMode: null | Dispatch<SetStateAction<boolean>>;}export const ThemeContext = createContext<Theme>({isDarkMode: false,setIsDarkMode: null,});export function useDarkMode() {const { isDarkMode, setIsDarkMode } = useContext(ThemeContext);return [isDarkMode, setIsDarkMode as Dispatch<SetStateAction<boolean>>] as const;}type AppOwnProps = {isDarkModeCookie: boolean;};export default function MyApp({ Component, pageProps, isDarkModeCookie }: AppProps & AppOwnProps) {const [isDarkMode, setIsDarkMode] = useState(isDarkModeCookie);return (<ThemeContext.Provider value={{ isDarkMode, setIsDarkMode }}><Component {...pageProps} /></ThemeContext.Provider>);}
Looks good!
Avoiding the White Flash with Cookies
Let's get back to our problem of solving the white flash.
Our cookie-based approach will go something like the following: on request, the user will
automatically send their cookies to the server. Before the server generates the initial HTML, it
checks to see if the user sent an isDarkMode
cookie. If so, the server generates the initial HTML
with the theme considered. If there's no cookie, the server creates an isDarkMode
cookie set to
false
, generates the initial HTML with the default theme, and returns both to the user. On the
client, we'll create a useEffect
to mutate the cookie when the global setIsDarkMode
setter is
called so the theme is persisted on subsequent requests.
Just a heads up: since we're in the custom pages/_app.tsx
, we can't use getServerSideProps
.
However, we can use getInitialProps
:
// pages/_app.tsximport { createContext, Dispatch, SetStateAction, useContext, useEffect, useState } from "react";import App, { AppContext, AppInitialProps, AppProps } from "next/app";import * as cookie from "cookie";const ONE_YEAR = 60 * 60 * 24 * 365;// set up contexttype AppOwnProps = {isDarkModeCookie: boolean;};export default function MyApp({ Component, pageProps, isDarkModeCookie }: AppProps & AppOwnProps) {const [isDarkMode, setIsDarkMode] = useState(isDarkModeCookie);useEffect(() => {document.cookie = cookie.serialize("isDarkMode", String(isDarkMode), {httpOnly: false,maxAge: ONE_YEAR,path: "/",});}, [isDarkMode]);return (<ThemeContext.Provider value={{ isDarkMode, setIsDarkMode }}><Component {...pageProps} /></ThemeContext.Provider>);}MyApp.getInitialProps = async (context: AppContext): Promise<AppOwnProps & AppInitialProps> => {const pageProps = await App.getInitialProps(context);const { req, res } = context.ctx;if (!req || !res) {return {...pageProps,isDarkModeCookie: false,};}const isDarkModeCookie = cookie.parse(req.headers.cookie || "").isDarkMode as string | undefined;if (isDarkModeCookie === undefined) {res.setHeader("Set-Cookie",cookie.serialize("isDarkMode", String(false), {httpOnly: false,maxAge: ONE_YEAR,path: "/",}),);return {...pageProps,isDarkModeCookie: false,};}return {...pageProps,isDarkModeCookie: isDarkModeCookie === "true",};};
Let's walk through the code.
- First, we
await App.getInitialProps(context)
to get the props for the user's current page:pageProps
- we'll need these for later when we merge the page's own props with our additionalisDarkModeCookie
prop. - Next, we grab
req
andres
fromcontext.ctx
and check if they're defined. This is a crucial check: althoughgetInitialProps
runs on the server during the first page load, it'll also run on the client during client-side navigations! Thankfully, though, since client-side state is preserved during client-side transitions,isDarkModeCookie
is effectively ignored whengetInitialProps
runs on the client since theisDarkMode
state is already initialized with the instance ofisDarkModeCookie
returned from the first call on the server. Funky! - To set the cookie, we set the
Set-Cookie
header using the cookie npm package. Although it's a little barebones, I decided to go with it since it has ~50 million weekly downloads, and it gets the job done getInitialProps
returns the current page's props, along withisDarkModeCookie
. InMyApp
, we useisDarkModeCookie
to initialize theisDarkMode
state, and passisDarkMode
andsetIsDarkMode
down through context. In our app, we presumably useuseDarkMode
to accessisDarkMode
, and we render our app on the server with the preferred theme.- Finally, we set up a
useEffect
so whensetIsDarkMode
is called, theisDarkMode
cookie is set accordingly.
If the idiosyncrasies of getInitialProps
are too much for you, I'll quickly outline an alternative
approach with getServerSideProps
. Be warned though, every page in the pages
directory will need
to be wrapped in context!
Alternate approach with getServerSideProps
// i.e. pages/blog.tsximport { createContext, Dispatch, SetStateAction, useContext, useEffect, useState } from "react";import * as cookie from "cookie";import { GetServerSideProps, InferGetServerSidePropsType } from "next";const ONE_YEAR = 60 * 60 * 24 * 365;interface Theme {isDarkMode: boolean;setIsDarkMode: null | Dispatch<SetStateAction<boolean>>;}export const ThemeContext = createContext<Theme>({isDarkMode: false,setIsDarkMode: null,});export function useDarkMode() {const { isDarkMode, setIsDarkMode } = useContext(ThemeContext);// move the useEffect to useDarkMode to avoid repeating in each PageuseEffect(() => {document.cookie = cookie.serialize("isDarkMode", String(isDarkMode), {httpOnly: false,maxAge: ONE_YEAR,path: "/",});}, [isDarkMode]);return [isDarkMode, setIsDarkMode as Dispatch<SetStateAction<boolean>>] as const;}export default function Blog({isDarkModeCookie,}: InferGetServerSidePropsType<typeof getServerSideProps>) {const [isDarkMode, setIsDarkMode] = useState(isDarkModeCookie);return (<ThemeContext.Provider value={{ isDarkMode, setIsDarkMode }}>{/* ... */}</ThemeContext.Provider>);}export const getServerSideProps = (async ({ req, res }) => {// no longer need to check that req and res are defined// since getServerSideProps only runs on the server!const isDarkModeCookie = cookie.parse(req.headers.cookie || "").isDarkMode as string | undefined;if (isDarkModeCookie === undefined) {res.setHeader("Set-Cookie",cookie.serialize("isDarkMode", String(false), {httpOnly: false,maxAge: ONE_YEAR,path: "/",}),);return {props: {isDarkModeCookie: false,},};}return {props: {isDarkModeCookie: isDarkModeCookie === "true",},};}) satisfies GetServerSideProps<{ isDarkModeCookie: boolean }>;
For most use-cases, this is where I'd stop. Our code will remember a theme preference across requests, and generate server-rendered HTML for the user without a flash of the default theme. However, there's one question left unanswered: what to do with a user's os-level theme preference.
Bonus: Accounting For an OS-Level Theme Preference
Unfortunately, it's impossible to detect the user's os-level theme preference on the server for the
first request: it's only accessible on the client through a media query. What we can do, however, is
render the default theme on the first request, then set the os-level theme preference in the
isDarkMode
cookie so that all subsequent requests use the correct theme. However, if the user
manually changes their theme to be different than their os-level preference, we should respect that
as well. Let's update our code from above:
// pages/_app.tsximport { createContext, Dispatch, SetStateAction, useContext, useEffect, useState } from "react";import App, { AppContext, AppInitialProps, AppProps } from "next/app";import * as cookie from "cookie";interface Theme {isDarkMode: boolean;setIsDarkMode: null | Dispatch<SetStateAction<boolean | null>>;}export const ThemeContext = createContext<Theme>({isDarkMode: false,setIsDarkMode: null,});type AppOwnProps = {isDarkModeCookie: boolean | null;};export default function MyApp({ Component, pageProps, isDarkModeCookie }: AppProps & AppOwnProps) {const [isDarkMode, setIsDarkMode] = useState(isDarkModeCookie);useEffect(() => {if (isDarkMode === null) {const media = window.matchMedia("(prefers-color-scheme: dark)");document.cookie = cookie.serialize("isDarkMode", String(media.matches), {httpOnly: false,maxAge: ONE_YEAR,path: "/",});// don't call setIsDarkMode or you might have a flash!return;}document.cookie = cookie.serialize("isDarkMode", String(isDarkMode), {httpOnly: false,maxAge: ONE_YEAR,path: "/",});}, [isDarkMode, isDarkModeCookie]);return (<ThemeContext.Providervalue={{isDarkMode: isDarkMode ?? false,setIsDarkMode,}}><Component {...pageProps} /></ThemeContext.Provider>);}MyApp.getInitialProps = async (context: AppContext): Promise<AppOwnProps & AppInitialProps> => {const pageProps = await App.getInitialProps(context);const { req, res } = context.ctx;if (!req || !res) {return {...pageProps,isDarkModeCookie: null,};}const isDarkModeCookie = cookie.parse(req.headers.cookie || "").isDarkMode as string | undefined;if (isDarkModeCookie === undefined) {return {...pageProps,isDarkModeCookie: null,};}return {...pageProps,isDarkModeCookie: isDarkModeCookie === "true",};};
It's a bit more complicated! For our initial state, we use isDarkModeCookie
, and update our state
to accept null
values.
Let's consider the condition in the useEffect
: If the isDarkMode
state is null
, there must be
no isDarkModeCookie
set, which means this is the first time a user has visited the site. Let's use
a media query to check if the user has an os-level theme preference, and if they do, set the
isDarkMode
cookie accordingly. Why not also call setIsDarkMode
to match this preference? If
there was a mismatch between the default theme and the os-level theme preference, updating the state
would cause a rerender and create a flash - exactly what we're trying to avoid!
Instead, let's allow our state and cookie to be misaligned for just a little while. In fact, as soon
as the user directly sets the state with setIsDarkMode
, isDarkMode
won't be null
, we'll skip
the if
block, and we'll align the cookie and state once again.
I'll leave it up to you to decide if the improved user experience is worth the extra code complexity. Thanks for reading!
you might also like:
Implementing Dark Mode: A Client-Side Approach with LocalStorage
March 17, 2024
A client-only approach using os-level theme preferences and local storage