Material UI Internals: Architecture and the Package Dependency Pyramid
Prerequisites
- ›React fundamentals (components, hooks, context)
- ›Basic CSS-in-JS concepts (styled-components or Emotion patterns)
- ›Familiarity with npm/pnpm and monorepo concepts
Material UI Internals: Architecture and the Package Dependency Pyramid
Material UI is one of the most widely used React component libraries in the world, yet few developers look beneath the surface of import Button from '@mui/material/Button'. This series changes that. Over six articles, we'll trace every line of consequence — from the moment your JSX invokes a component to the instant CSS hits the DOM. We start here, with the architecture that makes the whole system possible.
Material UI isn't a single package. It's a carefully layered monorepo where each package has a strict dependency contract with the layers beneath it. Understanding this pyramid is the key to understanding everything else in the codebase.
Monorepo Structure: pnpm Workspaces, Lerna, and Nx
The repository is organized around three workspace categories defined in the root workspace file:
packages:
- packages/*
- packages/mui-envinfo/test
- packages-internal/*
- docs
- test
- test/*
| Directory | Purpose |
|---|---|
packages/* |
Published npm packages (@mui/material, @mui/system, @mui/utils, etc.) |
packages-internal/* |
Internal tooling not published to npm (markdown processing, docs utilities) |
docs/ |
The Next.js documentation site at mui.com |
test/ |
Cross-package integration tests |
Three tools collaborate to manage this monorepo, each handling a distinct concern:
- pnpm manages package installation and workspace linking. The
workspace:^protocol inpackage.jsondependency fields tells pnpm to resolve packages from the local workspace rather than the registry. - Nx handles build caching and task orchestration. Its configuration is lean — just target defaults for
buildandcopy-licensetasks with cache outputs specified. - Lerna (historically) handles versioning and publishing to npm.
The Nx configuration at nx.json is notably minimal:
{
"targetDefaults": {
"build": {
"cache": true,
"dependsOn": ["copy-license", "^build"],
"outputs": ["{projectRoot}/build", "{projectRoot}/dist", "{projectRoot}/.next"]
}
}
}
The "dependsOn": ["^build"] entry is critical: it means that before building any package, Nx first builds all of that package's dependencies. This ensures the four-layer pyramid builds bottom-up.
The Four-Layer Dependency Pyramid
Every component you render from @mui/material sits atop a precise dependency hierarchy. Understanding these layers is fundamental to the rest of this series.
graph BT
L1_TYPES["@mui/types"]
L1_UTILS["@mui/utils"]
L1_THEMING["@mui/private-theming"]
L2["@mui/styled-engine"]
L3["@mui/system"]
L4["@mui/material"]
L1_TYPES --> L1_UTILS
L1_UTILS --> L3
L1_THEMING --> L3
L2 --> L3
L3 --> L4
L1_UTILS --> L4
style L4 fill:#1976d2,color:#fff
style L3 fill:#42a5f5,color:#fff
style L2 fill:#90caf9,color:#000
style L1_TYPES fill:#bbdefb,color:#000
style L1_UTILS fill:#bbdefb,color:#000
style L1_THEMING fill:#bbdefb,color:#000
Layer 1 — Foundation: @mui/types provides shared TypeScript utilities. @mui/utils provides runtime helpers like deepmerge, composeClasses, generateUtilityClass, and formatMuiErrorMessage. @mui/private-theming provides the base ThemeContext and ThemeProvider.
Layer 2 — Styled Engine: @mui/styled-engine wraps Emotion's styled, css, and keyframes APIs behind a thin abstraction. This is the adapter layer that enables engine-swapping.
Layer 3 — Styling System: @mui/system provides createStyled (the factory function at the heart of all MUI styling), sx prop processing, theme construction, CSS variables support, and layout primitives like Box.
Layer 4 — Components: @mui/material is the component library itself — Button, TextField, Dialog, and 60+ more. Every component uses styled from Layer 3, which uses the engine from Layer 2.
You can verify this hierarchy directly in the package dependencies. In packages/mui-material/package.json:
"dependencies": {
"@mui/system": "workspace:^",
"@mui/types": "workspace:^",
"@mui/utils": "workspace:^",
...
}
And in packages/mui-system/package.json:
"dependencies": {
"@mui/private-theming": "workspace:^",
"@mui/styled-engine": "workspace:^",
"@mui/types": "workspace:^",
"@mui/utils": "workspace:^",
...
}
Tip: When debugging a Material UI issue, identify which layer is involved first. A styling problem is likely in
@mui/system(Layer 3), while a theme token issue could be in@mui/material/styles(Layer 4). The pyramid tells you where to look.
The Styled-Engine Abstraction
The styled-engine layer is elegant in its simplicity. The entire Emotion adapter is a single file — packages/mui-styled-engine/src/index.js:
import emStyled from '@emotion/styled';
import { serializeStyles as emSerializeStyles } from '@emotion/serialize';
export default function styled(tag, options) {
const stylesFactory = emStyled(tag, options);
// dev-mode validation omitted for clarity
return stylesFactory;
}
export function internal_mutateStyles(tag, processor) {
if (Array.isArray(tag.__emotion_styles)) {
tag.__emotion_styles = processor(tag.__emotion_styles);
}
}
export function internal_serializeStyles(styles) {
wrapper[0] = styles;
return emSerializeStyles(wrapper);
}
export { ThemeContext, keyframes, css } from '@emotion/react';
This file does three things: wraps Emotion's styled with dev-mode validation, exposes internal_mutateStyles for post-hoc style manipulation, and exposes internal_serializeStyles for pre-hashing CSS at definition time.
The styled-components alternative at packages/mui-styled-engine-sc/src/index.js provides the identical API surface but maps to styled-components internals:
flowchart LR
SC["@mui/styled-engine-sc"]
EM["@mui/styled-engine"]
SYS["@mui/system"]
SC -- "styled, keyframes, css" --> SYS
EM -- "styled, keyframes, css" --> SYS
SC2["styled-components"] --> SC
EM2["@emotion/styled"] --> EM
The critical difference is in internal_serializeStyles. The Emotion adapter actually pre-serializes styles into hashed CSS strings. The styled-components adapter is a no-op — return styles — because styled-components doesn't expose an equivalent serialization API. This has performance implications we'll explore in Article 2.
Barrel Exports and the One-Level Import Rule
Material UI's public API is exposed through barrel files — index files that re-export from child modules. The main barrel at packages/mui-material/src/index.js follows a consistent pattern:
export { default as Button } from './Button';
export * from './Button';
export { default as TextField } from './TextField';
export * from './TextField';
// ...60+ more components
This pattern supports two import styles: import { Button } from '@mui/material' (barrel import) and import Button from '@mui/material/Button' (direct import). The ESLint configuration at eslint.config.mjs enforces a one-level import rule within the monorepo:
const OneLevelImportMessage = [
'Prefer one level nested imports to avoid bundling everything in dev mode',
'or breaking CJS/ESM split.',
].join('\n');
const NO_RESTRICTED_IMPORTS_PATTERNS_DEEPLY_NESTED = [
{
group: ['@mui/*/*/*'],
message: OneLevelImportMessage,
},
];
This means import Button from '@mui/material/Button' is acceptable, but import ButtonRoot from '@mui/material/Button/Button' is not. The rule exists for two reasons: it prevents deep imports from pulling in private implementation details, and it ensures bundler tree-shaking works correctly by maintaining a predictable module graph.
flowchart TD
A["import Button from '@mui/material/Button'"] -->|"✅ One level"| B["packages/mui-material/src/Button/index.js"]
B --> C["./Button.js (implementation)"]
D["import Button from '@mui/material/Button/Button'"] -->|"❌ Two levels"| C
E["import { Button } from '@mui/material'"] -->|"✅ Barrel"| F["packages/mui-material/src/index.js"]
F --> B
Scoped Theming with THEME_ID
One of the most subtle architectural decisions in Material UI is the THEME_ID system. Every Material UI component extracts its theme not from the raw theme context, but from a scoped key within it.
The identifier is defined in packages/mui-material/src/styles/identifier.ts:
export default '$$material';
This ID is then passed to createStyled in packages/mui-material/src/styles/styled.js:
import createStyled from '@mui/system/createStyled';
import defaultTheme from './defaultTheme';
import THEME_ID from './identifier';
import rootShouldForwardProp from './rootShouldForwardProp';
const styled = createStyled({
themeId: THEME_ID,
defaultTheme,
rootShouldForwardProp,
});
When a component renders, createStyled resolves the theme through attachTheme:
function attachTheme(props, themeId, defaultTheme) {
props.theme = isObjectEmpty(props.theme)
? defaultTheme
: props.theme[themeId] || props.theme;
}
sequenceDiagram
participant Component as Button
participant Styled as createStyled
participant Theme as ThemeContext
Component->>Styled: render with props
Styled->>Theme: read theme from context
Theme-->>Styled: { $$material: {...}, $$joy: {...} }
Styled->>Styled: attachTheme(props, '$$material', default)
Styled-->>Component: props.theme = theme['$$material']
This design enables a powerful use case: running Material UI and Joy UI on the same page with completely independent themes. Each library scopes to its own key within a shared theme context, so ThemeProvider can wrap both libraries without conflicts.
Tip: If you're building a component library on top of
@mui/system, define your ownTHEME_ID(e.g.,'$$mylib'). This ensures your library's theme doesn't collide with Material UI when consumers use both.
What's Next
With the architectural foundation established, we now understand where code lives and how packages relate. In Part 2, we'll dive into the single most important function in the entire codebase: createStyled. It's the factory function that transforms a simple styled(Button)({...}) call into a fully themed, variant-matched, sx-prop-enabled React component — and its three-zone expression pipeline is more intricate than you'd expect.