DevStacked
Next.js FundamentalsSeptember 20, 202617 min read

MUI v9 Theme Setup in Next.js 16: Full 2026 Guide

You installed @mui/material, dropped a <Button> on the page, and it looked... fine. Then you added a custom color, refreshed the page, and for a split second saw the default MUI blue before your color kicked in. Or worse — your styles randomly break depending on which route loads first.

None of that is a coincidence. It's what happens when MUI's styling engine (Emotion) meets Next.js's server-streaming App Router without the right glue in between. MUI ships an official package specifically for this, and most tutorials online still show the old Pages Router setup or skip the dark mode flash entirely.

By the end of this guide, you'll have a complete, production-ready MUI theme wired into a Next.js 16 App Router project — proper SSR style injection, a TypeScript-friendly custom theme, Google Fonts through next/font, a flash-free light/dark mode toggle, and the correct way to write mode-aware styles so they don't flicker.


Why This Is Tricky

Next.js's App Router renders on the server and streams HTML to the browser in chunks. MUI's default styling engine, Emotion, generates CSS at render time and needs to know how to collect all of that generated CSS and inject it into the <head> before the streamed HTML reaches the browser — otherwise you get unstyled flashes or duplicated styles.

On top of that, MUI components that use hooks (like useState for a theme toggle) need to be Client Components, but your root layout is a Server Component by default. If the Server vs. Client Component split isn't second nature to you yet, it's worth a quick read before this guide — almost every mistake in a MUI + Next.js setup comes down to getting that boundary wrong. Get it wrong here and you'll see:

  • A flash of unstyled or default-themed content (FOUC)
  • Hydration mismatch warnings in the console
  • Dark mode flashing light for a split second on every page load

MUI's official @mui/material-nextjs package exists specifically to solve the streaming/cache problem, and a few small App Router patterns solve the rest. Let's build it properly.


Prerequisites

This guide assumes:

  • Next.js 16 with the App Router
  • React 19.2 (Next.js 16's default)
  • TypeScript in strict mode
  • npm (swap for yarn/pnpm commands as needed)

Step 1: Install MUI and Its Dependencies

MUI's core package is @mui/material, and it needs Emotion (its default style engine) plus the official Next.js integration package.

npm install @mui/material @emotion/react @emotion/styled @mui/material-nextjs @mui/icons-material

Here's what each package actually does:

PackagePurpose
@mui/materialThe core component library (buttons, inputs, layout, etc.)
@emotion/react / @emotion/styledMUI's default CSS-in-JS engine — generates the actual styles
@mui/material-nextjsOfficial adapter that makes Emotion's SSR streaming work correctly with the App Router
@mui/icons-materialMaterial Design icon set (optional, but almost every project ends up needing it)

Version note: This guide targets MUI v9 with Next.js 16. Keep the MUI packages on compatible v9 releases, and use the v16-appRouter integration entry point.

💡 Tip: MUI's current package layout uses Node.js package exports to provide appropriate ESM and CommonJS entry points, so this setup doesn't require special MUI bundler configuration.

A note on the versioned import paths

You'll notice the import in the next step is @mui/material-nextjs/v16-appRouter, not just @mui/material-nextjs. This isn't cosmetic — the package ships a separate entry point per Next.js major version, because each new Next.js major has occasionally changed how App Router streaming/rendering internals work under the hood. Using the wrong one for your Next.js version is a real (if often silently-working) mismatch, so pick the entry point that matches your installed Next.js major:

Next.js version@mui/material-nextjs entry point
Next.js 13v13-appRouter
Next.js 14v14-appRouter
Next.js 15v15-appRouter
Next.js 16v16-appRouter

⚠️ Common Mistake: Copy-pasting an older tutorial's v15-appRouter (or even older) import into a Next.js 16 project. It may still build without errors, but you're no longer using the entry point MUI actually maintains against your Next.js version — always match the number to your installed next major.


Step 2: Add the App Router Cache Provider

This is the piece almost every outdated tutorial skips or gets wrong. AppRouterCacheProvider is what collects Emotion's generated CSS during server rendering and streams it into the <head> correctly, instead of leaving it to render inline in the <body> (which causes the unstyled flash).

Create your root layout:

// app/layout.tsx
import type { Metadata } from "next";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v16-appRouter";

export const metadata: Metadata = {
  title: "My MUI App",
  description: "A Next.js 16 app styled with MUI",
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>
        <AppRouterCacheProvider>
          {children}
        </AppRouterCacheProvider>
      </body>
    </html>
  );
}

What's happening here: AppRouterCacheProvider wraps everything under <body>. As Next.js streams your page in chunks, this provider intercepts every bit of CSS Emotion generates along the way and inserts it into <head> ahead of the content that needs it — so by the time a chunk of HTML reaches the browser, its styles are already there.

💡 If you also need to set page titles, Open Graph tags, or other <head> data alongside this setup, that's handled separately through Next.js's own Metadata API — the metadata export above is a small example of it, not something AppRouterCacheProvider manages.

At this point, run your dev server and add any MUI component — the styles should already apply correctly with no flash. Now let's make the theme actually yours.


Step 3: Create a Custom Theme File

A "theme" in MUI is just a JavaScript object describing your colors, typography, spacing, and component overrides. Centralizing it in one file means every component in your app pulls from the same design tokens instead of scattered inline styles.

// theme/theme.ts
"use client";

import { createTheme } from "@mui/material/styles";

const theme = createTheme({
  cssVariables: true,
  palette: {
    primary: {
      main: "#0F62FE",
    },
    secondary: {
      main: "#6929C4",
    },
  },
  shape: {
    borderRadius: 10,
  },
  typography: {
    fontFamily: "var(--font-roboto)",
  },
});

export default theme;

What's happening here:

  • "use client" is required at the top of this file. MUI recommends creating the theme in a Client Component module when using it with the App Router because the theme object is consumed by the client-side ThemeProvider, and the theme contains more than plain serializable configuration data.
    Related: Server vs. Client Components in Next.js 16 covers exactly what does and doesn't need this directive.
  • cssVariables: true tells MUI to expose theme tokens as CSS custom properties such as --mui-palette-primary-main. MUI components can then reference these variables, which is particularly useful for switching color schemes without requiring a full CSS regeneration. This is the modern MUI approach — it plays nicer with server rendering and is what makes the flash-free dark mode in Step 5 possible.
  • palette.primary / palette.secondary override MUI's default blue/purple with your own brand colors. Every component (<Button color="primary">, <Chip color="secondary">, etc.) automatically picks these up.
  • typography.fontFamily references a CSS variable we'll define in the next step using next/font — this is the correct way to combine MUI with Next.js's font optimization.

💡 Tip: Keep this file focused on your app's design tokens (colors, spacing, typography), and use component-level sx props for one-off styling. Don't try to theme every possible edge case up front — add overrides as you actually need them.


Step 4: Load Fonts with next/font and Wire Up the Theme Provider

Next.js's built-in font optimization (next/font) self-hosts Google Fonts at build time, avoiding a render-blocking request to Google's font CDN. MUI's docs specifically recommend combining it with the theme via a CSS variable rather than passing the font name as a plain string.

// app/layout.tsx
import type { Metadata } from "next";
import { Roboto } from "next/font/google";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v16-appRouter";
import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import theme from "@/theme/theme";

const roboto = Roboto({
  weight: ["300", "400", "500", "700"],
  subsets: ["latin"],
  display: "swap",
  variable: "--font-roboto",
});

export const metadata: Metadata = {
  title: "My MUI App",
  description: "A Next.js 16 app styled with MUI",
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en" className={roboto.variable}>
      <body>
        <AppRouterCacheProvider>
          <ThemeProvider theme={theme}>
            <CssBaseline />
            {children}
          </ThemeProvider>
        </AppRouterCacheProvider>
      </body>
    </html>
  );
}

What's happening here:

  • Roboto({ ... variable: "--font-roboto" }) generates a CSS variable holding the correct font-family string, and Next.js injects the actual @font-face rules automatically — no manual <link> tags.
  • Adding className={roboto.variable} to <html> makes that CSS variable available everywhere in your app, including inside the MUI theme file from Step 3 (fontFamily: "var(--font-roboto)").
  • <ThemeProvider theme={theme}> makes your custom theme available to every MUI component via context — this is what actually applies your colors and typography.
  • <CssBaseline /> is MUI's version of a CSS reset — it normalizes margins, sets a consistent box-sizing, and applies your theme's background/text colors to <body>. Almost every MUI project should include it once, at the root.

⚠️ Common Mistake: Passing theme={theme} where theme was created without "use client" in its own file, then importing it into a Server Component layout directly. Keep the theme creation itself client-side (as shown in Step 3) to avoid subtle serialization issues — ThemeProvider here is already a Client Component internally, so this composition is safe.

At this point, your fonts, colors, and typography are fully wired up with zero flash of unstyled content. Now let's add dark mode.


Step 5: Add Flash-Free Dark Mode

The trickiest part of theming in Next.js isn't picking colors — it's avoiding the flash where a user's saved "dark mode" preference briefly shows light mode before JavaScript hydrates and corrects it. MUI's cssVariables mode ships a dedicated component for exactly this: InitColorSchemeScript.

First, update the theme to support both color schemes:

// theme/theme.ts
"use client";

import { createTheme } from "@mui/material/styles";

const theme = createTheme({
  cssVariables: {
    colorSchemeSelector: "class",
  },
  colorSchemes: {
    light: {
      palette: {
        primary: { main: "#0F62FE" },
        secondary: { main: "#6929C4" },
      },
    },
    dark: {
      palette: {
        primary: { main: "#7CA9FF" },
        secondary: { main: "#B490E8" },
      },
    },
  },
  shape: {
    borderRadius: 10,
  },
  typography: {
    fontFamily: "var(--font-roboto)",
  },
});

export default theme;

What changed: instead of one flat palette, we now define colorSchemes.light and colorSchemes.dark, each with its own palette. colorSchemeSelector: "class" tells MUI to switch schemes by toggling a CSS class (rather than data- attributes or prefers-color-scheme alone), which gives you full manual control over the toggle.

Next, add InitColorSchemeScript to the root layout, before anything else renders:

// app/layout.tsx
import { InitColorSchemeScript } from "@mui/material";
// ...other imports from Step 4

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en" className={roboto.variable} suppressHydrationWarning>
      <body>
        <InitColorSchemeScript attribute="class" />
        <AppRouterCacheProvider>
          <ThemeProvider theme={theme}>
            <CssBaseline />
            {children}
          </ThemeProvider>
        </AppRouterCacheProvider>
      </body>
    </html>
  );
}

What's happening here: InitColorSchemeScript injects a tiny, synchronous inline script that runs before React hydrates. It reads the user's saved preference (from localStorage) and applies the right class to <html> immediately — so the correct theme is already active by the time the browser paints anything. This is exactly what eliminates the flash. If you want the deeper "why" behind pre-hydration scripts like this one, Next.js Rendering Strategies Explained covers how streaming and hydration fit together.

⚠️ If you don't add suppressHydrationWarning to your <html> tag, you will see warnings about "Extra attributes from the server" because InitColorSchemeScript updates that element.

Now build a simple toggle button as a Client Component:

// components/theme-toggle.tsx
"use client";

import IconButton from "@mui/material/IconButton";
import Brightness4Icon from "@mui/icons-material/Brightness4";
import Brightness7Icon from "@mui/icons-material/Brightness7";
import { useColorScheme } from "@mui/material/styles";

export default function ThemeToggle() {
  const { mode, setMode } = useColorScheme();

  if (!mode) {
    // Renders on the server before the client has resolved a mode —
    // returning null here avoids a hydration mismatch.
    return null;
  }

  return (
    <IconButton
      onClick={() => setMode(mode === "light" ? "dark" : "light")}
      color="inherit"
      aria-label="Toggle light and dark theme"
    >
      {mode === "dark" ? <Brightness7Icon /> : <Brightness4Icon />}
    </IconButton>
  );
}

What's happening here: useColorScheme() is a hook MUI provides specifically for cssVariables-based themes — it gives you the current mode and a setMode function that updates both the DOM class and localStorage for you. The if (!mode) return null guard matters: on the very first client render, mode can briefly be undefined before MUI resolves it, and rendering nothing during that split second avoids a server/client mismatch warning.

💡 Tip: mode can be "light", "dark", or "system". If you want a three-way toggle (light/dark/follow OS), cycle through all three values instead of just two in the onClick handler.


Step 6: Write Mode-Aware Styles the Right Way — Avoid theme.palette.mode

Here's a mistake that's easy to make even after everything above is set up correctly: once a component needs a color that depends on light vs. dark mode, it's tempting to reach for a plain conditional:

// ❌ Don't do this with CSS variables enabled
<Card
  sx={(theme) => ({
    backgroundColor: theme.palette.mode === "dark" ? "#000" : "#fff",
  })}
/>

This looks harmless, but MUI's own docs explicitly warn against it: theme.palette.mode reflects whatever mode was resolved at render time, and on the server that's always a guess (or the default). The result is the exact SSR flicker you just spent Step 5 eliminating — the server renders one color, the client corrects it a moment later, and you see a flash.

The fix is theme.applyStyles(), which generates real CSS rules scoped to each mode instead of picking one value at render time:

// ✅ Correct — no flicker, works with CSS variables
import Card from "@mui/material/Card";

<Card
  sx={[
    {
      backgroundColor: "#fff",
    },
    (theme) =>
      theme.applyStyles("dark", {
        backgroundColor: "#000",
      }),
  ]}
/>;

What's happening here: instead of computing a single color based on the current mode, applyStyles("dark", {...}) generates mode-specific CSS that is activated by the color-scheme selector configured in the theme. Both the light and dark rules can exist in the stylesheet, while the active selector on <html> determines which styles apply.

The same pattern works with the styled() API:

// components/themed-box.tsx
"use client";

import { styled } from "@mui/material/styles";

export const ThemedBox = styled("div")(({ theme }) => [
  {
    color: "#111",
    backgroundColor: theme.palette.background.paper,
  },
  theme.applyStyles("dark", {
    color: "#fff",
    backgroundColor: theme.palette.grey[900],
  }),
]);

⚠️ Common Mistake: Sprinkling theme.palette.mode === "dark" checks throughout sx props "just to get something working," then wondering why dark mode flickers even after adding InitColorSchemeScript. InitColorSchemeScript only prevents the class on <html> from being wrong on first paint — it does nothing to fix a component that's still choosing its color at render time instead of via CSS. Audit your sx props for palette.mode checks and swap them for applyStyles() as you find them.

💡 Tip: You can pass an array of style objects (as shown above) or plain objects — theme.applyStyles() composes fine with any other static styles in the same sx array.


Step 7: Verify Everything Works

Drop the toggle into a page and confirm the full setup:

// app/page.tsx
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import ThemeToggle from "@/components/theme-toggle";

export default function HomePage() {
  return (
    <Stack spacing={3} sx={{ p: 4 }}>
      <Stack direction="row" sx={{ justifyContent: "center", alignItems: "center" }}>
        <Typography variant="h4">MUI + Next.js 16</Typography>
        <ThemeToggle />
      </Stack>
      <Typography variant="body1">
        This button, color, and font are all coming from your custom theme.
      </Typography>
      <Button variant="contained" color="primary">
        Themed Button
      </Button>
    </Stack>
  );
}

Refresh the page a few times with dark mode on — there should be zero flash of the wrong theme, and the button color should match your custom palette instead of MUI's default blue.

app/page.tsx does not need "use client" just because it renders ThemeToggle. Next.js allows Server Components to render Client Components; only the interactive subtree needs the client boundary.


Frequently Asked Questions

Do I need @mui/material-nextjs if I'm only using the Pages Router?

The Next.js integration package supports both routers. For the Pages Router, use the versioned pagesRouter entry point that matches your Next.js version, such as v13-pagesRouter for Next.js 13. The setup differs from the App Router and uses _document.tsx/_app.tsx rather than the App Router cache-provider setup.

Why does my theme file need "use client" if ThemeProvider is used in a Server Component layout?

ThemeProvider itself is already a Client Component internally, so it's safe to render from a Server Component layout. The "use client" on the theme file is a safety measure — createTheme() returns a rich object with methods, and keeping its creation explicitly client-side avoids subtle serialization issues as your theme grows more complex.

Why can't I just check theme.palette.mode === "dark" in my styles?

You can, but only if you don't care about the SSR flicker — MUI's own docs say directly that any theme.palette.mode check will flash the wrong mode on first load when CSS variables are enabled, because the server has no way to know the client's saved preference at render time. theme.applyStyles("dark", {...}) avoids this entirely by generating mode-scoped CSS instead of picking a value during render. See Step 6.

Can I use MUI with Tailwind CSS in the same Next.js project?

Yes. The important issue is CSS cascade and style precedence rather than class-name collisions. MUI's enableCssLayer option places generated MUI styles inside an @layer mui layer, making it easier to control how MUI styles interact with Tailwind CSS, CSS Modules, and other global styles.

My styles show up correctly but flash unstyled for a split second — what's wrong?

Almost always means AppRouterCacheProvider isn't wrapping your whole app, or it's nested in the wrong place (it needs to be a direct wrapper inside <body>, around everything else). Double-check Step 2's structure.

Do I still need CssBaseline?

No, CssBaseline isn't technically required. It is commonly recommended because it provides MUI's baseline/reset styles and applies theme-aware background and text defaults.


Wrapping Up

You now have a complete, flash-free MUI setup in Next.js 16 — the official AppRouterCacheProvider handling SSR streaming correctly (using the Next.js 16-matched v16-appRouter entry point), a typed custom theme with your own colors and next/font-optimized typography, a working light/dark toggle powered by MUI's native CSS variables, and mode-aware component styles written with theme.applyStyles() instead of a flicker-prone palette.mode check. This is the same foundation you'd want under a real dashboard, admin panel, or SaaS product.

From here, a natural next step is exploring MUI's styled() API and sx prop for component-level customization, or building out a full design system with typed theme augmentation (declare module "@mui/material/styles") for custom palette keys.

Continue Learning

📦 Source Code: View on GitHub

muimaterial-uinextjsthemingtypescriptreact
Share On