How to Add a Custom Class (e.g., 'dark') to Body in Next.js Using _document.js: Overcoming SSR & Client-Side Rendering Hurdles

When building modern web applications, theming (e.g., light/dark modes) has become a standard feature. A common requirement is to apply a custom class (like dark) to the <body> tag to style the entire page. However, in Next.js, this task is complicated by its hybrid rendering model—Server-Side Rendering (SSR) and Client-Side Rendering (CSR). If not handled correctly, you may encounter issues like Flash of Unstyled Content (FOUC) or hydration mismatches, where the server-rendered HTML doesn’t match the client-side state.

This blog will guide you through adding a dynamic custom class (e.g., dark) to the <body> tag in Next.js using _document.js, with a focus on overcoming SSR/CSR hurdles. We’ll cover static and dynamic class use cases, cookie-based persistence, and client-side synchronization.

Table of Contents#

  1. Understanding the Problem: SSR vs. CSR in Next.js
  2. What is _document.js?
  3. Step-by-Step Guide: Adding a Custom Class to <body>
  4. Overcoming SSR/CSR Hurdles
  5. Testing the Implementation
  6. App Router Note (Next.js 13+)
  7. Conclusion
  8. References

Understanding the Problem: SSR vs. CSR in Next.js#

Next.js uses SSR to render pages on the server and send fully built HTML to the client, improving performance and SEO. However, the <body> tag is part of the initial HTML sent by the server. If you try to add a class to <body> only client-side (e.g., with useEffect), the server-rendered HTML will lack the class, leading to:

  • FOUC: The page briefly renders without the class (e.g., light mode) before the client-side code adds it (e.g., switching to dark mode).
  • Hydration Mismatches: React expects the server-rendered HTML to match the client-side DOM. If the client immediately modifies the <body> class during hydration, React throws an error.

To avoid this, we need to ensure the <body> class is set both on the server (initial render) and client (dynamic updates).

What is _document.js?#

_document.js is a custom Next.js document used to augment the HTML and <body> tags. Unlike regular pages, it’s only rendered on the server, making it the ideal place to modify the initial HTML structure. It extends Next.js’s default Document component and allows you to override the rendering of <html>, <head>, and <body> tags.

Note: _document.js is specific to the Pages Router (Next.js’s traditional routing system). For the App Router (Next.js 13+), the approach differs (covered briefly in section 6).

Step-by-Step Guide: Adding a Custom Class to <body>#

We’ll start with static classes (simple) and progress to dynamic classes (e.g., dark based on user preference).

3.1 Creating/Modifying _document.js#

First, create pages/_document.js (if it doesn’t exist). This file extends Document from next/document and overrides its methods to customize the output.

Basic _document.js Structure:

// pages/_document.js
import Document, { Html, Head, Main, NextScript } from 'next/document';
 
class MyDocument extends Document {
  render() {
    return (
      <Html lang="en">
        <Head />
        <body>
          <Main /> {/* Renders the page component */}
          <NextScript /> {/* Renders Next.js scripts */}
        </body>
      </Html>
    );
  }
}
 
export default MyDocument;

This is the default structure. We’ll modify the <body> tag’s className here.

3.2 Adding a Static Class#

For static classes (e.g., a class that never changes), simply add the className prop to the <body> tag in _document.js:

// pages/_document.js
import Document, { Html, Head, Main, NextScript } from 'next/document';
 
class MyDocument extends Document {
  render() {
    return (
      <Html lang="en">
        <Head />
        {/* Add static class "my-static-class" to body */}
        <body className="my-static-class">
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}
 
export default MyDocument;

Verification: Refresh the page and inspect the <body> tag in DevTools. It will now include class="my-static-class".

3.3 Adding a Dynamic Class (e.g., dark for Theming)#

Dynamic classes (e.g., dark based on user preference) require coordination between the server (initial render) and client (updates). Here’s how to do it:

Step 1: Persist User Preference with Cookies#

Server-side code (like _document.js) can’t access client-side storage (e.g., localStorage), but it can read cookies. We’ll use cookies to persist the user’s theme preference (e.g., theme=dark).

Step 2: Modify _document.js to Read Cookies#

Override getInitialProps in MyDocument to read the theme cookie and set the <body> class dynamically on the server.

Update _document.js:

// pages/_document.js
import Document, { Html, Head, Main, NextScript } from 'next/document';
 
// Helper to parse cookies from the request header
const getCookieValue = (cookies, name) => {
  return cookies?.split(';').find(c => c.trim().startsWith(`${name}=`))?.split('=')[1];
};
 
class MyDocument extends Document {
  static async getInitialProps(ctx) {
    // Get the default props from Document
    const initialProps = await Document.getInitialProps(ctx);
    
    // Read cookies from the request (ctx.req is the Node.js request object)
    const cookies = ctx.req?.headers.cookie;
    const theme = getCookieValue(cookies, 'theme') || 'light'; // Default to "light"
 
    return { ...initialProps, theme };
  }
 
  render() {
    const { theme } = this.props; // Passed from getInitialProps
    return (
      <Html lang="en">
        <Head />
        {/* Add "dark" class if theme is "dark" */}
        <body className={theme === 'dark' ? 'dark' : ''}>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}
 
export default MyDocument;

How It Works:

  • getInitialProps runs on the server and reads the theme cookie from the request headers.
  • The theme prop is passed to render(), where the <body> class is set to dark if theme === 'dark'.

Step 3: Client-Side Synchronization#

Now, add client-side logic to:

  1. Read the theme cookie on initial load (to sync with server-rendered state).
  2. Toggle the dark class and update the cookie when the user changes preferences.

Create a Theme Toggle Component:

// components/ThemeToggle.js
import { useEffect, useState } from 'react';
import { setCookie, removeCookie } from '../lib/cookies'; // Use a cookie library
 
const ThemeToggle = () => {
  const [isDark, setIsDark] = useState(false);
 
  // Initialize state from cookie on client load
  useEffect(() => {
    const savedTheme = document.cookie.includes('theme=dark');
    setIsDark(savedTheme);
    // Sync body class with initial state (prevents FOUC)
    if (savedTheme) document.body.classList.add('dark');
  }, []);
 
  // Toggle theme and update cookie
  const toggleTheme = () => {
    const newTheme = !isDark;
    setIsDark(newTheme);
 
    if (newTheme) {
      document.body.classList.add('dark');
      setCookie('theme', 'dark', { maxAge: 365 * 24 * 60 * 60 }); // Persist for 1 year
    } else {
      document.body.classList.remove('dark');
      removeCookie('theme');
    }
  };
 
  return (
    <button onClick={toggleTheme}>
      {isDark ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
    </button>
  );
};
 
export default ThemeToggle;

Cookie Utility (lib/cookies.js):
Use a lightweight cookie library like js-cookie or implement simple helpers:

// lib/cookies.js
export const setCookie = (name, value, options = {}) => {
  document.cookie = `${name}=${value}; ${Object.entries(options)
    .map(([k, v]) => `${k}=${v}`)
    .join('; ')}`;
};
 
export const removeCookie = (name) => {
  setCookie(name, '', { maxAge: -1 });
};

Step 4: Use the Toggle Component in a Page#

Add the ThemeToggle to a page (e.g., pages/index.js) to let users switch themes:

// pages/index.js
import ThemeToggle from '../components/ThemeToggle';
 
export default function Home() {
  return (
    <div>
      <h1>My App</h1>
      <ThemeToggle />
    </div>
  );
}

Overcoming SSR/CSR Hurdles#

4.1 Flash of Unstyled Content (FOUC)#

FOUC occurs if the server-rendered <body> class doesn’t match the client’s preference. We fixed this by:

  • Using getInitialProps in _document.js to read the theme cookie and set the correct initial class.
  • Syncing the client-side state with the cookie on mount (via useEffect in ThemeToggle).

4.2 Hydration Mismatches#

React throws hydration errors if the client modifies the DOM during hydration. To avoid this:

  • Never modify the <body> class during the initial render (e.g., in useState or top-level code).
  • Use useEffect with an empty dependency array ([]) to update the class after hydration.

Testing the Implementation#

  1. Initial Load: Check the page source (right-click → "View Page Source"). The <body> tag should have class="dark" if the theme cookie is set to dark.
  2. Client-Side Toggle: Click the ThemeToggle button. The <body> class should update immediately, and the theme cookie should reflect the new preference.
  3. Hard Refresh: After setting the cookie to dark, hard-refresh the page. The server should render <body class="dark"> with no FOUC.

App Router Note (Next.js 13+)#

The App Router (using app/ directory) doesn’t use _document.js. Instead:

  • For static classes: Modify the root layout (app/layout.js) and add className to the <body> tag directly (since layouts in App Router are server-rendered).
  • For dynamic classes: Use a client-side component (e.g., ThemeProvider) with useEffect to update the <body> class, and persist preferences in cookies. The server can read cookies via cookies() in layout.js to set the initial class.

Example App Router Layout:

// app/layout.js
import { cookies } from 'next/headers';
 
export default function RootLayout({ children }) {
  const theme = cookies().get('theme')?.value || 'light';
  return (
    <html lang="en">
      <body className={theme === 'dark' ? 'dark' : ''}>
        {children}
      </body>
    </html>
  );
}

Conclusion#

Adding a custom class like dark to the <body> in Next.js requires careful handling of SSR and CSR. By using _document.js (Pages Router) or cookies with layouts (App Router), you can ensure the class is set consistently on the server and client, eliminating FOUC and hydration errors. Remember to persist user preferences in cookies for seamless server-client synchronization.

References#