首页
/ Material UI + Next.js 集成实践:从 Emotion 缓存、next/font 到 CSS 层与 Link 适配

Material UI + Next.js 集成实践:从 Emotion 缓存、next/font 到 CSS 层与 Link 适配

2026-09-07 11:46:54作者:余洋婵Anita

Material UI 基于 Emotion 组织样式,在 Next.js 的服务端渲染(SSR)与流式渲染场景下,必须先把 Emotion cache「接线」妥当,才能让 CSS 按预期注入 <head>。本篇文章以仓库内 skills/material-ui-nextjs/AGENTS.md 技能文档及其对应的 Next.js 集成指南 为主体,完整讲解 App Router 与 Pages Router 两套接入路径、@mui/material-nextjs 的 Provider 用法、next/font 主题化、CSS theme variables 的 SSR 注意事项,以及与 Tailwind/CSS Modules 通过 @layer 共存、Button component={Link} 的适配方法,帮助你在搭建或排查 Next.js 项目中的 MUI 渲染问题时一步到位。

为什么需要 @mui/material-nextjs

Material UI 的样式引擎 Emotion 默认在浏览器端把生成的 CSS 插入页面。但在 Next.js 中,HTML 是在服务端渲染并通过流式(streaming)分块推送给客户端的,若不在服务端收集 Emotion 生成的 CSS,就会导致样式缺失、顺序错乱,或样式被注入 <body> 而非 <head>@mui/material-nextjs 包正是为此提供开箱即用的缓存 Provider:

  • App RouterAppRouterCacheProvider——在服务端渲染与流式输出过程中收集 MUI System 生成的 CSS,让样式稳定挂载在 <head>
  • Pages RouterAppCacheProvider + DocumentHeadTags——在 _document 阶段抽取样式标签,在 _app 中承接客户端缓存。

从源码结构看,当前仓库 packages/mui-material-nextjs 内的版本为 9.4.0,与 Material UI v9 保持同步;技能文档亦声明其目标版本为 >=9.0.0 <10.0.0(见 metadata.json)。

一个容易被忽略的前提是:Material UI 组件以客户端组件("use client")形式分发,它们依然参与 SSR,但并非 React Server Components;同时 @mui/material-nextjs 的导入路径带 v1X- 前缀(如 v15-appRouterv16-appRouter),必须与所用 Next.js 大版本匹配。

App Router 接入(推荐路径)

1. 安装依赖

确保项目已安装 @mui/materialnext,再追加两个包:

pnpm add @mui/material-nextjs @emotion/cache

App Router 默认只需要 @emotion/cache@emotion/server 是 Pages Router 场景才需要的。

2. 在根布局中包裹 AppRouterCacheProvider

app/layout.tsx 中,将 <body> 之下的所有内容用 AppRouterCacheProvider 包裹,并从与 Next 大版本匹配的入口导入:

import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
// 若不是 Next 15,使用对应的 v1X-appRouter,如 v14-appRouter、v16-appRouter

export default function RootLayout(props) {
  return (
    <html lang="en">
      <body>
        <AppRouterCacheProvider>{props.children}</AppRouterCacheProvider>
      </body>
    </html>
  );
}

仓库示例 examples/material-ui-nextjs/src/app/layout.js 展示了完整形态——它基于 Next 16 从 @mui/material-nextjs/v16-appRouter 导入,并在 Provider 内依次放入 ThemeProviderCssBaseline 再渲染 children

:::note 为什么不加会出问题? AppRouterCacheProvider 负责在服务端渲染时收集 MUI System 生成的 CSS。不使用它通常仍可运行,但样式可能只出现在 <body> 中;把它加上后,样式才会稳定进入 <head>。 :::

3. 可选:自定义缓存 options

AppRouterCacheProvider 接受 options prop,可覆盖 Emotion 的默认缓存选项。最常见的调整是修改样式插入时的 CSS key——MUI 默认 key 为 mui,可改成 css

<AppRouterCacheProvider options={{ key: 'css' }}>
  {children}
</AppRouterCacheProvider>

options 直接透传给 Emotion cache 的选项(非本仓库内资源,作外围理解即可),因此你还可以传入 nonceinsertionPoint 等 Emotion 支持的字段。

4. URL 状态与 Suspense 边界

仪表盘、内部工具类应用常常用 MUI 客户端组件(TableTabsTextField 等)承载筛选、标签页、分页等由 URL 驱动的 UI,并通过 next/navigationuseSearchParams() 读取查询串。Next.js 要求:凡是读取 URL、使路由进入客户端渲染状态的代码,其外层必须有 <Suspense> 边界,否则构建或运行时可能报「missing Suspense boundary」之类的错误(行为随 Next.js 版本与静态/动态渲染配置而异)。

推荐的工程结构是:

  1. 尽可能让 app/.../page.tsx 保持为服务端组件;
  2. 把真正调用 useSearchParams、渲染 MUI 组件的子树拆成独立客户端文件;
  3. 在服务端 page 中用 <Suspense> 包裹该客户端子树。

注意 fallback 不能随意用 null:对于占据布局空间的工具条、筛选栏等 UI,空 fallback 会让首屏流式 HTML 缺一块,待客户端 hydration 后才突然出现,造成明显的布局抖动(CLS)。应使用与最终 UI 尺寸、结构接近的占位内容,例如用 MUI 的 Skeleton 配合 Stack/Box,并设置相同的 minHeight、弹性方向与断点:

import { Suspense } from 'react';
import Box from '@mui/material/Box';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import OrdersToolbar from './OrdersToolbar';

function OrdersToolbarFallback() {
  return (
    <Stack
      direction="row"
      spacing={2}
      useFlexGap
      sx={{ flexWrap: 'wrap', alignItems: 'center', minHeight: 56 }}
    >
      <Skeleton variant="rounded" height={40} sx={{ minWidth: 200, flexGrow: { xs: 1, sm: 0 } }} />
      <Skeleton variant="rounded" width={120} height={40} />
      <Box sx={{ flexGrow: 1 }} />
      <Skeleton variant="rounded" width={100} height={40} />
    </Stack>
  );
}

export default function Page() {
  return (
    <Suspense fallback={<OrdersToolbarFallback />}>
      <OrdersToolbar />
    </Suspense>
  );
}

其中 OrdersToolbar 是标注了 'use client'、内部调用 useSearchParams() 的组件。请按真实工具条(或筛选行)的布局微调 fallback 的尺寸。

Pages Router 接入

Pages Router 的接线分布在两个文件:_document 负责服务端抽取样式,_app 负责客户端缓存承接。

1. 安装依赖

pnpm add @mui/material-nextjs @emotion/cache @emotion/server

2. pages/_document.tsx

  • v1X-pagesRouter 入口导入 DocumentHeadTagsdocumentGetInitialProps
  • <Head> 内渲染 <DocumentHeadTags {...props} />
  • 把 Document 的 getInitialProps 指派为 documentGetInitialProps
import { DocumentHeadTags, documentGetInitialProps } from '@mui/material-nextjs/v15-pagesRouter';
// 或使用匹配的 v1X-pagesRouter

export default function MyDocument(props) {
  return (
    <Html lang="en">
      <Head>
        <DocumentHeadTags {...props} />
        ...
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

MyDocument.getInitialProps = async (ctx) => {
  const finalProps = await documentGetInitialProps(ctx);
  return finalProps;
};

DocumentHeadTags 会把服务端收集到的样式标签(Emotion <style>)渲染进 <Head>,这是样式进入 <head> 的关键。

3. pages/_app.tsx

用同一大版本入口导入的 AppCacheProvider 作为根元素包裹应用:

import { AppCacheProvider } from '@mui/material-nextjs/v15-pagesRouter';

export default function MyApp(props) {
  return (
    <AppCacheProvider {...props}>
      <Head>...</Head>
      ...
    </AppCacheProvider>
  );
}

AppCacheProvider 负责在客户端为 hydration 提供一致的缓存实例,服务端渲染时收集的样式标签才不会与客户端重新生成的样式打架。

4. 自定义缓存与级联层(@layer)

Pages Router 自定义缓存的传入口在 documentGetInitialProps 的选项里:把自定义 Emotion cache 作为 emotionCache 传入。若要开启 @layer,则使用 @mui/material-nextjs 提供的 createEmotionCache({ enableCssLayer: true }),并保证 _document_app 使用同一套缓存模式:

// pages/_document.tsx
import { createEmotionCache } from '@mui/material-nextjs/v15-pagesRouter';

MyDocument.getInitialProps = async (ctx) => {
  const finalProps = await documentGetInitialProps(ctx, {
    emotionCache: createEmotionCache({ enableCssLayer: true }),
  });
  return finalProps;
};
// pages/_app.tsx
import { createEmotionCache } from '@mui/material-nextjs/v15-pagesRouter';

const clientCache = createEmotionCache({ enableCssLayer: true });

export default function MyApp({ emotionCache = clientCache }) {
  return <AppCacheProvider emotionCache={emotionCache}>{/* ... */}</AppCacheProvider>;
}

5. 可选:App 增强插件(plugins)

documentGetInitialProps 还支持 plugins 数组,用于叠加其他 SSR 样式方案(如 JSS、styled-components)。每个插件需提供两个属性:

  • enhanceApp:接收 App 组件、返回新 App 组件的高阶组件;
  • resolveProps:接收 initial props、返回新 props 对象的函数。

执行顺序是:先按顺序执行所有插件的 enhanceApp,再按同样顺序执行 resolveProps。下面是一个同时接入 styled-components 与 JSS 的完整示例:

import { ServerStyleSheet } from 'styled-components';

MyDocument.getInitialProps = async (ctx) => {
  const jssSheets = new JSSServerStyleSheets();
  const styledComponentsSheet = new ServerStyleSheet();

  try {
    const finalProps = await documentGetInitialProps(ctx, {
      emotionCache: createEmotionCache(),
      plugins: [
        {
          // styled-components
          enhanceApp: (App) => (props) => styledComponentsSheet.collectStyles(<App {...props} />),
          resolveProps: async (initialProps) => ({
            ...initialProps,
            styles: [styledComponentsSheet.getStyleElement(), ...initialProps.styles],
          }),
        },
        {
          // JSS
          enhanceApp: (App) => (props) => jssSheets.collect(<App {...props} />),
          resolveProps: async (initialProps) => {
            const css = jssSheets.toString();
            return {
              ...initialProps,
              styles: [
                ...initialProps.styles,
                <style id="jss-server-side" key="jss-server-side" dangerouslySetInnerHTML={{ __html: css }} />,
                <style id="insertion-point-jss" key="insertion-point-jss" />,
              ],
            };
          },
        },
      ],
    });
    return finalProps;
  } finally {
    styledComponentsSheet.seal();
  }
};

6. TypeScript 类型

TS 项目需把 DocumentHeadTagsProps 并入 Document 组件的 props 类型(从同一导入路径取):

import type { DocumentHeadTagsProps } from '@mui/material-nextjs/v15-pagesRouter';

export default function MyDocument(props: DocumentProps & DocumentHeadTagsProps) {
  ...
}

可对照仓库现成的 Pages Router + TypeScript 示例 examples/material-ui-nextjs-pages-router-ts 进行整体参照。

字体:next/font 与主题变量

Next.js 自带字体优化(next/font),自托管字体可避免布局偏移。接入 MUI 的关键是:主题模块若被服务端组件消费,需要 'use client' 指令;字体 CSS 变量挂在 <html>className 上;主题里用 var(--font-…) 引用该变量。

App Router 的标准做法如下。第一步,新建 src/theme.ts,使用 var(--font-roboto) 作为 typography.fontFamily

'use client';
import { createTheme } from '@mui/material/styles';

const theme = createTheme({
  typography: {
    fontFamily: 'var(--font-roboto)',
  },
});

export default theme;

第二步,在 app/layout.tsx 加载字体、把 variable 设到 <html className> 上,并用 ThemeProvider 注入主题:

import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
import { Roboto } from 'next/font/google';
import { ThemeProvider } from '@mui/material/styles';
import theme from '../theme';

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

export default function RootLayout(props) {
  const { children } = props;
  return (
    <html lang="en" className={roboto.variable}>
      <body>
        <AppRouterCacheProvider>
          <ThemeProvider theme={theme}>{children}</ThemeProvider>
        </AppRouterCacheProvider>
      </body>
    </html>
  );
}

Provider 的层级关系可以归纳为(详见 reference.md):

<html>
  <body>
    <AppRouterCacheProvider>
      <ThemeProvider theme={theme}>
        {children}
      </ThemeProvider>
    </AppRouterCacheProvider>
  </body>
</html>

Pages Router 是同样的思路,只是放在 pages/_app.tsx:加载字体后用 AppCacheProvider 包裹,再套 ThemeProvider,并把 roboto.variable 放到 <main className={roboto.variable}>(或 html 上)即可。

CSS theme variables 与 SSR 防闪烁

当使用 CSS theme variables 体系(把设计令牌输出为 CSS 变量)时,需要在 createTheme 中开启 cssVariables: true

'use client';
const theme = createTheme({
  cssVariables: true,
});

由于 colorSchemes(明/暗模式)属性是客户端在首次渲染时写进 DOM 的,若 <html> 上缺少对应属性,会产生 React hydration mismatch。因此:

  • <html> 上添加 suppressHydrationWarning
  • 若要避免 SSR 阶段明暗主题闪烁(先亮后暗),按 CSS theme variables 文档中「Preventing SSR flickering」的方案,尽早执行 InitColorSchemeScript

colorSchemesdefaultMode 的完整说明见 docs/data/material/customization 目录下的 CSS theme variables 主题文档(skills 中亦有指针),原理是服务端先行输出 data-color-scheme 标记,客户端 hydration 前不产生可见跳变。

与其他样式方案共存:enableCssLayer

如果你同时使用 Tailwind CSS、CSS Modules 或其他全局 CSS 来定制 MUI 组件,直接让两套样式互相覆盖会有「谁后加载谁赢」的不确定性。MUI 的解法是把其输出包进 CSS 级联层 @layer mui——因为匿名层(Tailwind、CSS Modules、普通 CSS)优先级高于具名层,全局样式即可按预期覆盖 MUI 默认样式。

App Router 只需一行:

<AppRouterCacheProvider options={{ enableCssLayer: true }}>

Pages Router 则在 _document_app 中共同使用 createEmotionCache({ enableCssLayer: true })(见上文级联层一节)。仓库的 App Router 示例 examples/material-ui-nextjs/src/app/layout.js 正是开启 options={{ enableCssLayer: true }} 的写法。关于 @layer 语义可参考 MDN 的 CSS @layer 文档(非本仓库资源,此处仅为概念背景)。

Linkcomponent prop:避开 Next.js v16 的 Client 边界限制

给 MUI 组件(如 Button)传入 component={Link} 是常见的路由适配手法。但 Next.js v16 开始,直接把 next/link 的默认导出塞进 component 会触发 “Functions cannot be passed directly to Client Components” 错误。解决办法是做一个带 'use client' 的小包装再导出,仓库示例 examples/material-ui-nextjs/src/components/Link.js 正是这种形态:

'use client';
import Link, { LinkProps } from 'next/link';

export default Link;

然后替换页面里的导入来源并使用 component prop:

import Link from '../components/Link';
// ...
<Button component={Link} href="/about" variant="contained">
  Go to About Page
</Button>

主题级、页面级的整体 Link 适配思路可参考仓库内 docs/data/material/integrations 下的路由集成文档以及 Pages Router 示例。

小结与进一步阅读

接入工作可以浓缩为三个判断:

  1. 样式去向:是否用 AppRouterCacheProvider / AppCacheProvider + DocumentHeadTags 把 SSR 与流式阶段的 Emotion CSS 送进 <head>
  2. 导入路径@mui/material-nextjs/v1X-appRouterv1X-pagesRouter 的后缀是否与 Next.js 大版本一致;
  3. 边界与共存:URL 驱动的 MUI 子树是否包了 <Suspense>(且 fallback 不塌陷布局),与其他 CSS 方案共存时是否开启 enableCssLayer,以及字体/主题变量、Link 包装是否就位。

若要在实际仓库中进一步对照与排查,推荐阅读:

登录后查看全文
热门项目推荐
相关项目推荐