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/pnpmcommands 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:
| Package | Purpose |
|---|---|
@mui/material | The core component library (buttons, inputs, layout, etc.) |
@emotion/react / @emotion/styled | MUI's default CSS-in-JS engine — generates the actual styles |
@mui/material-nextjs | Official adapter that makes Emotion's SSR streaming work correctly with the App Router |
@mui/icons-material | Material 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-appRouterintegration 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 13 | v13-appRouter |
| Next.js 14 | v14-appRouter |
| Next.js 15 | v15-appRouter |
| Next.js 16 | v16-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 installednextmajor.
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 — themetadataexport above is a small example of it, not somethingAppRouterCacheProvidermanages.
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-sideThemeProvider, 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: truetells 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.secondaryoverride MUI's default blue/purple with your own brand colors. Every component (<Button color="primary">,<Chip color="secondary">, etc.) automatically picks these up.typography.fontFamilyreferences a CSS variable we'll define in the next step usingnext/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
sxprops 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-facerules 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 consistentbox-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}wherethemewas 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 —ThemeProviderhere 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
suppressHydrationWarningto your<html>tag, you will see warnings about "Extra attributes from the server" becauseInitColorSchemeScriptupdates 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:
modecan 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 theonClickhandler.
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 throughoutsxprops "just to get something working," then wondering why dark mode flickers even after addingInitColorSchemeScript.InitColorSchemeScriptonly 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 yoursxprops forpalette.modechecks and swap them forapplyStyles()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 samesxarray.
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.tsxdoes not need "use client" just because it rendersThemeToggle. 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
- Server vs Client Components in Next.js 16 (2026 Guide) — go deeper on the Server/Client boundary this guide's
"use client"theme file relies on. - How to Add Metadata in Next.js 16 (2026 Beginner's Guide) — pair this theme setup with proper per-page titles, Open Graph tags, and SEO metadata.
- Next.js Rendering Strategies Explained: SSR, SSG, CSR, ISR & Cache Components (2026) — understand the streaming/hydration model that makes
InitColorSchemeScriptnecessary in the first place. - SaaS Starter Architecture with Next.js 2026 — see where a themed component library like this one fits into a larger app structure.
📦 Source Code: View on GitHub
More in Next.js Fundamentals
View AllNext.js Cache Components Migration Guide (2026)
Migrate to Next.js Cache Components — enable cacheComponents, replace revalidate/fetchCache with use cache and cacheLife, and fix Suspense errors.
Server vs Client Components in Next.js 16 (2026 Guide)
Learn when to use Server Components vs Client Components in Next.js 16 App Router, with real code examples, common mistakes, and a clear decision framework.
Error Handling in Next.js 16: The Complete 2026 Guide
Learn error handling in Next.js 16 with error.tsx, global-error.tsx, not-found.tsx, Server Actions, and Route Handlers — with production-ready code.
Route Handlers Explained in Next.js 16
Learn Route Handlers in Next.js 16 App Router — the route.ts file convention, HTTP methods, dynamic params, caching with Cache Components, and building a real API.