Server-Side Rendering with Material UI: SSR, SSG, ISR
Material UI (MUI) is one of the most popular React component libraries, but using it in server-rendered applications requires special attention. Unlike client-only apps, server-rendered apps must handle styles, theme injection, and hydration correctly to avoid mismatches between server and client output. This tutorial walks through integrating MUI with Next.js using Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR).
Why SSR with Material UI Matters
By default, MUI generates styles at runtime using Emotion (or styled-components). When you render on the server without proper setup, the server sends HTML without critical CSS, leading to a Flash of Unstyled Content (FOUC). Worse, React hydration can fail because the class names generated on the server differ from those on the client. Proper SSR setup ensures:
- Faster First Contentful Paint (FCP) by inlining critical CSS
- Better SEO since crawlers receive fully styled HTML
- Consistent hydration without React warnings
- Improved Core Web Vitals scores
Project Setup
Start by creating a Next.js project and installing MUI along with its Emotion dependencies. We will use the App Router (Next.js 13+) but the concepts also apply to the Pages Router.
npx create-next-app@latest mui-ssr-demo
cd mui-ssr-demo
npm install @mui/material @emotion/react @emotion/styled @emotion/cache @emotion/server
npm install @mui/icons-material
Configuring the Emotion Cache
The key to SSR with MUI is a custom Emotion cache that prepends style tags to the document head. This ensures styles are extracted in the correct order on both server and client.
Create a file at app/theme.js to define your theme and cache:
'use client';
import * as React from 'react';
import createCache from '@emotion/cache';
import { CacheProvider } from '@emotion/react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { useServerInsertedHTML } from 'next/navigation';
const theme = createTheme({
palette: {
mode: 'light',
primary: { main: '#1976d2' },
secondary: { main: '#dc004e' },
},
typography: {
fontFamily: 'Roboto, sans-serif',
},
});
export default function ThemeRegistry({ children }) {
const [{ cache, flush }] = React.useState(() => {
const cache = createCache({ key: 'mui', prepend: true });
cache.compat = true;
const prevInsert = cache.insert;
let inserted = [];
cache.insert = (...args) => {
const serialized = args[1];
if (cache.inserted[serialized.name] === undefined) {
inserted.push(serialized.name);
}
return prevInsert(...args);
};
const flush = () => {
const prevInserted = inserted;
inserted = [];
return prevInserted;
};
return { cache, flush };
});
useServerInsertedHTML(() => {
const names = flush();
if (names.length === 0) {
return null;
}
let styles = '';
for (const name of names) {
styles += cache.inserted[name];
}
return (
<style
key={cache.key}
data-emotion={`${cache.key} ${names.join(' ')}`}
dangerouslySetInnerHTML={{ __html: styles }}
/>
);
});
return (
<CacheProvider value={cache}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</CacheProvider>
);
}
Wiring the Theme Registry into the Root Layout
Open app/layout.js and wrap your application with the ThemeRegistry component. This ensures every server-rendered route has access to the theme and the Emotion cache.
import ThemeRegistry from './theme';
export const metadata = {
title: 'MUI SSR Demo',
description: 'Server-Side Rendering with Material UI',
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<ThemeRegistry>{children}</ThemeRegistry>
</body>
</html>
);
}
Server-Side Rendering (SSR) with MUI
SSR renders the page on every request. This is ideal for pages with dynamic, user-specific, or frequently changing data. In the App Router, server components render on the server by default, so no special configuration is needed beyond the cache setup.
Creating an SSR Page
Create app/products/page.js. This page fetches data on the server on every request and renders MUI components with styles inlined.
import { Box, Typography, Card, CardContent, Grid } from '@mui/material';
async function fetchProducts() {
const res = await fetch('https://api.example.com/products', {
cache: 'no-store',
});
return res.json();
}
export default async function ProductsPage() {
const products = await fetchProducts();
return (
<Box sx={{ p: 4 }}>
<Typography variant="h3" gutterBottom>
Products (SSR)
</Typography>
<Grid container spacing={3}>
{products.map((product) => (
<Grid item xs={12} sm={6} md={4} key={product.id}>
<Card>
<CardContent>
<Typography variant="h6">{product.name}</Typography>
<Typography color="text.secondary">
${product.price}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
);
}
The cache: 'no-store' option tells Next.js to never cache the fetch, forcing a fresh render on every request. The styles generated by MUI are extracted by the ThemeRegistry and injected into the HTML response.
Static Site Generation (SSG) with MUI
SSG pre-renders pages at build time. This produces static HTML files that can be served from a CDN, giving the best performance. SSG is perfect for content that rarely changes, such as blog posts, documentation, or marketing pages.
Creating an SSG Page
To enable SSG, remove the no-store option or explicitly set cache: 'force-cache'. Next.js will render the page once at build time and serve the static output.
import { Box, Typography, Paper, Divider } from '@mui/material';
async function fetchPost(slug) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: false },
});
return res.json();
}
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map((post) => ({ slug: post.slug }));
}
export default async function BlogPostPage({ params }) {
const post = await fetchPost(params.slug);
return (
<Box sx={{ maxWidth: 800, mx: 'auto', p: 4 }}>
<Typography variant="h2" gutterBottom>
{post.title}
</Typography>
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
By {post.author} ยท {post.date}
</Typography>
<Divider sx={{ my: 3 }} />
<Paper elevation={0} sx={{ p: 3 }}>
<Typography variant="body1" component="div">
{post.content}
</Typography>
</Paper>
</Box>
);
}
The generateStaticParams function tells Next.js which dynamic routes to pre-render at build time. Each generated page includes fully styled MUI markup with no runtime JavaScript required for the initial paint.
Incremental Static Regeneration (ISR) with MUI
ISR combines the performance of SSG with the freshness of SSR. Pages are generated statically but re-rendered in the background at a configurable interval. This is the best choice for pages that update periodically but do not need real-time data.
Creating an ISR Page
Use the next.revalidate option to set the regeneration interval in seconds. The following example revalidates every 60 seconds:
import { Box, Typography, Card, CardContent, Chip, Stack } from '@mui/material';
async function fetchNews() {
const res = await fetch('https://api.example.com/news', {
next: { revalidate: 60 },
});
return res.json();
}
export const revalidate = 60;
export default async function NewsPage() {
const articles = await fetchNews();
const generatedAt = new Date().toISOString();
return (
<Box sx={{ p: 4 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
<Typography variant="h3">Latest News</Typography>
<Chip label={`Updated: ${generatedAt}`} color="primary" variant="outlined" />
</Stack>
{articles.map((article) => (
<Card key={article.id} sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h5">{article.title}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{article.summary}
</Typography>
<Chip
size="small"
label={article.category}
sx={{ mt: 1 }}
/>
</CardContent>
</Card>
))}
</Box>
);
}
When a request comes in after the revalidation window, Next.js serves the stale static page immediately and triggers a background regeneration. The next visitor receives the freshly generated page. MUI styles are baked into both the stale and regenerated HTML, so there is never a flash of unstyled content.
Handling Client-Side Interactivity
Server components cannot use event handlers or state. For interactive MUI components like dialogs, menus, or forms, create client components. The ThemeRegistry cache works seamlessly across both server and client components.
'use client';
import * as React from 'react';
import {
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
} from '@mui/material';
export default function ContactForm() {
const [open, setOpen] = React.useState(false);
const [email, setEmail] = React.useState('');
const handleSubmit = (e) => {
e.preventDefault();
console.log('Submitting:', email);
setOpen(false);
};
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Contact Us
</Button>
<Dialog open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit}>
<DialogTitle>Send a Message</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Email Address"
type="email"
fullWidth
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" variant="contained">Send</Button>
</DialogActions>
</form>
</Dialog>
</>
);
}
You can import this client component directly into a server component page. Next.js handles the boundary automatically, and MUI styles for the client component are still extracted during SSR.
Pages Router Alternative
If you are using the Pages Router, the setup differs slightly. You need a custom _document.js and _app.js to handle style extraction on the server.
// pages/_document.js
import * as React from 'react';
import { Html, Head, Main, NextScript } from 'next/document';
import createEmotionServer from '@emotion/server/create-instance';
import theme, { createEmotionCache } from '../src/theme';
export default function MyDocument(props) {
const { emotionStyleTags } = props;
return (
<Html lang="en">
<Head>
<meta name="theme-color" content={theme.palette.primary.main} />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto" />
{emotionStyleTags}
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
MyDocument.getInitialProps = async (ctx) => {
const originalRenderPage = ctx.renderPage;
const cache = createEmotionCache();
const { extractCriticalToChunks } = createEmotionServer(cache);
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) =>
function EnhanceApp(props) {
return <App emotionCache={cache} {...props} />;
},
});
const initialProps = await ctx.defaultGetInitialProps(ctx);
const emotionStyles = extractCriticalToChunks(initialProps.html);
const emotionStyleTags = emotionStyles.styles.map((style) => (
<style
data-emotion={`${style.key} ${style.ids.join(' ')}`}
key={style.key}
dangerouslySetInnerHTML={{ __html: style.css }}
/>
));
return { ...initialProps, emotionStyleTags };
};
// pages/_app.js
import * as React from 'react';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { CacheProvider } from '@emotion/react';
import theme, { createEmotionCache } from '../src/theme';
const clientSideEmotionCache = createEmotionCache();
export default function MyApp(props) {
const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
return (
<CacheProvider value={emotionCache}>
<ThemeProvider theme={theme}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
</CacheProvider>
);
}
For SSR in the Pages Router, use getServerSideProps. For SSG, use getStaticProps with getStaticPaths. For ISR, add revalidate to the object returned by getStaticProps.
// pages/products.js (Pages Router ISR example)
export async function getStaticProps() {
const res = await fetch('https://api.example.com/products');
const products = await res.json();
return {
props: { products },
revalidate: 60,
};
}
Best Practices
- Use a consistent cache key: Always use the same Emotion cache key (e.g.,
'mui') across server and client to avoid class name mismatches during hydration. - Keep CssBaseline in the tree: CssBaseline normalizes styles and applies the MUI background color. Place it inside the ThemeProvider so it renders on the server.
- Avoid window references in server components: MUI components like
useMediaQueryrely onwindow. Use them only in client components or guard withtypeof window !== 'undefined'. - Minimize client-side JavaScript: Push interactive components to the leaves of your component tree. Keep layout and content as server components to reduce bundle size.
- Preload fonts: MUI's default typography uses Roboto. Load fonts via
next/fontor a<link>tag in the document head to prevent layout shift. - Choose the right rendering strategy: Use SSG for static content, ISR for periodically updated content, and SSR for real-time or user-specific data. You can mix strategies across different routes in the same application.
- Test hydration: After building, check the browser console for React hydration warnings. Mismatches usually indicate a cache or theme configuration issue.
- Use the latest MUI version: MUI v5 and v6 have first-class Emotion SSR support. If you are on v4, migration is strongly recommended for better SSR compatibility.
Conclusion
Server-side rendering with Material UI is straightforward once you understand the role of the Emotion cache. By wrapping your application in a ThemeRegistry that extracts and injects styles on the server, you get fully styled HTML for every rendering strategy Next.js offers. SSR gives you fresh data on every request, SSG delivers maximum performance for static content, and ISR provides a practical middle ground. Combine these strategies with thoughtful component boundaries between server and client, and you can build fast, SEO-friendly, visually consistent applications with Material UI and Next.js.