โ† Back to DevBytes

Server-Side Rendering with Material UI: SSR, SSG, ISR

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:

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

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.

โ€” Ad โ€”

Google AdSense will appear here after approval

โ† Back to all articles