How we migrated our codebase from MUI v5 to MUI v6
Upgrading a UI library sounds simple when you look at the final diff.
Change a few versions in package.json, run the build, fix some TypeScript errors, and you're done.
At least, that’s what I expected when I started working on our Material UI v5 → v6 migration.
In reality, the version bump was probably the smallest part of the work.
The migration touched dependencies, Vite configuration, TypeScript, component APIs, runtime behavior, tests, and a lot of legacy code that had accumulated over time.
More importantly, I learned that a large frontend migration isn’t about fixing everything as quickly as possible. It’s about controlling the scope, understanding the failure at each layer, and moving through the migration systematically.
This is how we approached it, what actually broke, and what I want to remember the next time I have to do something similar.
The first lesson: a framework upgrade is never just a version bump
The first thing we learned was that MUI didn’t exist in isolation.
Our application had several packages around MUI that either depended on older versions, were no longer needed, or had their own migration concerns.
Before changing @mui/material, we cleaned up some of that dependency graph.
We removed things we weren’t using anymore, including an unused tree-view package, an unused dropzone wrapper, and an alpha formik-mui integration. We replaced the Formik integration with a small wrapper around MUI's TextField and useField.
We also upgraded our snackbar library and an icon wrapper to versions compatible with the new MUI setup.
We removed direct @mui/system imports from application code where they could be replaced with the corresponding Material exports.
This made the actual MUI migration much easier to reason about.
The important lesson for me was:
Before upgrading a foundational dependency, reduce the number of things that depend on it.
A major UI-library migration is really an ecosystem migration.
We deliberately didn’t migrate everything
One of the best decisions we made was deciding what not to migrate.
Our target was:
@mui/material→ v6@mui/icons-material→ v6@mui/lab→ v6
But we deliberately kept:
@mui/styleson v5- existing
makeStyles/withStyles - our existing compatible date-picker version
- other modernization work that wasn’t required for MUI v6
@mui/styles was particularly important.
There is no v6 release of @mui/styles, and our application had hundreds of files using the legacy JSS APIs. Trying to remove all of that while also upgrading MUI would have turned one migration into two or three separate projects.
So we made a boundary:
Get Material onto v6 first. Modernize the legacy styling separately.
That was a surprisingly important lesson for me.
A migration doesn’t have to leave the codebase perfect. It needs to leave the codebase in a better and safer position for the next phase.
Turning the migration guide into a checklist
The MUI migration guide was useful, but reading documentation isn’t the same as having a migration plan.
We turned the documented breaking changes into a tracker.
For each component or API, we recorded:
- what changed
- whether it existed in our codebase
- what needed to be changed
- whether the change could be automated
- whether it needed manual testing
- any notes or edge cases
That changed the migration from:
“Let’s upgrade MUI and see what breaks.”
into:
“Let’s systematically work through the known migration surface.”
That distinction matters a lot in a large codebase.
It also made it easier to divide the work and know when a particular part of the migration was actually finished.
The actual version bump was surprisingly small
Once the dependency cleanup and scope were clear, the actual version changes were relatively straightforward.
We moved the core MUI packages to v6 while leaving @mui/styles on v5.
We also cleaned up imports that had previously come directly from @mui/system.
For example:
// before
import { Box } from '@mui/system';
import { styled } from '@mui/system';// after
import { Box } from '@mui/material';
import { styled } from '@mui/material/styles';The interesting part came after the version bump.
That’s when the real migration started.
Build tooling became part of the migration
Our application uses Vite, and some of the first problems we encountered had nothing to do with React components.
We ran into duplicate MUI instances in the bundle.
Vite could end up with multiple copies of @mui/system, which caused problems with theme and context identity.
The symptoms were confusing because they looked like MUI or React problems:
- theme values weren’t reaching styled components correctly
- styling behaved inconsistently
- components behaved differently depending on how they were imported
The underlying problem was the module graph.
We fixed this with dependency deduplication:
resolve: {
dedupe: ['@mui/material', '@mui/system'],
}That was one of those moments where I realized that understanding the bundler is part of understanding a frontend application.
The migration wasn’t just:
React + MUI
It was:
React + MUI + package resolution + Vite + module formats + dependency graph.
CJS vs ESM caused another round of problems
We also had an icon wrapper package whose CommonJS entry wasn’t being consumed cleanly by Vite.
We eventually aliased it to its ESM build.
That resolved build problems involving missing default exports and crashes around MUI’s internal icon metadata during development pre-bundling.
At one point we also experimented with optimizeDeps exclusions for MUI icons.
That helped as a temporary workaround, but after fixing the ESM resolution properly, we removed those exclusions.
That was another useful lesson:
A workaround that makes the error disappear isn’t necessarily the real fix.
It’s worth understanding why the workaround works before keeping it in the final configuration.
Transitive dependencies can break an otherwise correct migration
Another issue was react-is.
MUI v6 could pull in a newer react-is than our React 18 application expected. That caused problems with prop-type checks.
We explicitly pinned the resolution to the React 18-compatible version:
{
"resolutions": {
"react-is": "^18.3.1"
}
}We also made sure @types/react matched the React major version we were actually running.
This initially seemed like a tiny package-management detail.
It wasn’t.
One of the biggest things I took away from the migration was:
Transitive dependencies are part of the upgrade surface.
When something breaks during a framework migration, don’t only look at the package you intentionally upgraded. Look at what it brought with it.
Then came the TypeScript errors
Once the bundler and dependency issues were under control, we could start dealing with actual MUI v6 API changes.
These were easier to reason about because TypeScript gave us a clear list of things to fix.
Typography color
One example was Typography.
Some of our code passed custom theme colors directly through the color prop.
For example:
// before
<Typography color={theme.custom.blue}>MUI v6 became stricter about what the color prop accepts.
The fix was to move the custom color into sx:
// after
<Typography sx={{ color: theme.custom.blue }}>Instead of finding these manually, we searched the codebase for the relevant pattern and audited the results.
That became a recurring strategy throughout the migration:
Use search to discover the migration surface, then use TypeScript and runtime testing to discover what the search missed.
LoadingButton moved into the core Button
Another straightforward migration was LoadingButton.
Previously:
import { LoadingButton } from '@mui/lab';
<LoadingButton loading={isSaving}>
Save
</LoadingButton>The loading behavior moved into the core Button:
import Button from '@mui/material/Button';
<Button loading={isSaving}>
Save
</Button>There was one additional runtime consideration.
When the button was inside a Tooltip, we sometimes needed to wrap it in a <span> because disabled/loading buttons don't always provide a child that can accept the ref that Tooltip requires.
This became a pattern throughout the migration:
A TypeScript fix doesn’t necessarily mean the migration is finished.
ListItem migration: where codemods helped
The ListItem changes were a good example of where automation was useful but incomplete.
We had older ListItemSecondaryAction patterns that needed to move to the newer secondaryAction API.
The old structure looked roughly like:
<ListItemButton>
...
</ListItemButton>
<ListItemSecondaryAction>
<IconButton />
</ListItemSecondaryAction>The newer pattern moved the secondary action onto ListItem:
<ListItem
disablePadding
secondaryAction={<IconButton />}
>
<ListItemButton>
...
</ListItemButton>
</ListItem>We used the MUI codemod:
npx @mui/codemod@latest v6.0.0/list-item-button-prop src/It saved us from manually making every repetitive change.
But it wasn’t magic.
Some components had custom composition or CSS that depended on the old structure, so those still needed manual review.
This gave me a rule I’ll probably use for every future migration:
Let codemods handle repetitive transformations. Read every diff anyway.
Autocomplete and the move toward slots
Autocomplete had another API migration.
The old:
<Autocomplete
ListboxComponent={CustomListbox}
/>became:
<Autocomplete
slots={{
listbox: CustomListbox,
}}
/>The same general idea applies to other component overrides through slots and slotProps.
This was a useful reminder that migration work isn’t always about replacing one name with another.
Sometimes the API model itself has changed.
In this case, MUI is moving toward a more consistent slot-based composition model, so we had to understand the new structure rather than blindly rename props.
Not every breaking change showed up in TypeScript
This was probably the biggest difference between what I expected and what actually happened.
After fixing the compile errors, the application still wasn’t done.
We had to run the application and start looking at browser warnings and actual behavior.
Some of the issues included:
Tooltip and refs
Tooltips complained when their children were disabled buttons or other elements that couldn’t properly accept refs.
In affected cases, we wrapped the child in a <span>.
Menu children
Some menus were rendering fragments where MUI expected actual MenuItem children.
Fragments can be convenient in React, but they don’t always satisfy component-level assumptions about direct children.
Multiple inputs inside FormControl
We had a case where a Select was nested inside another input structure.
That resulted in multiple InputBase instances being associated with one FormControl.
The result wasn’t just a console warning. Layout behavior could break as well.
We fixed those cases by restructuring the component rather than trying to suppress the warning.
Controlled vs uncontrolled components
We also found cases where initial state values didn’t match what components such as Autocomplete or Tabs expected.
These aren’t necessarily obvious from a TypeScript error.
You find them by actually interacting with the application.
The Select menu width problem
One particularly interesting issue was the Select menu.
The menu is portaled to document.body, and the paper width can follow the content rather than the width of the trigger.
That meant some selects no longer looked like they did before the migration.
We solved this in our shared Select component rather than fixing every individual usage.
Get Sapna Kul’s stories in your inbox
Join Medium for free to get updates from this writer.
The basic idea was:
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState<number>();
<Box ref={ref}>
<Select
onOpen={() => setWidth(ref.current?.offsetWidth)}
MenuProps={{
slotProps: {
paper: {
sx: { width },
},
},
}}
/>
</Box>The important part wasn’t the exact implementation.
It was where we fixed it.
Instead of adding migration-specific code to dozens of call sites, we fixed the shared component once.
When a migration exposes a repeated behavioral problem, fix the abstraction rather than every usage.
The snackbar bug that looked like an MUI bug
One of the more frustrating issues actually came from a different package.
We upgraded our snackbar library from v2 to v3 around the same time as the MUI migration.
Its overload resolution changed.
In the older version, we had code along the lines of:
enqueueSnackbar(message, {
variant,
anchorOrigin,
content: ...
});In v3, passing a plain object as the first argument could cause it to be interpreted as options rather than the message.
That produced a strange symptom:
an empty grey snackbar appeared at the default position.
At first glance, it looked like something had gone wrong with MUI styling or our custom snackbar.
It wasn’t.
The fix was to use the new calling convention:
enqueueSnackbar('', {
content: (key) => (
<CustomSnackbar
id={key}
data={payload}
/>
),
anchorOrigin: {
vertical: 'bottom',
horizontal: 'right',
},
});This was a great example of why regression testing needs to cover the application, not just the package you think you’re migrating.
When multiple dependencies change together, the failure may happen somewhere that doesn’t look related to the original change.
Tests were another layer of the migration
Once the application behaved correctly, we still had the test suite.
MUI v6 lazy-loads ripple behavior, which affected tests that assumed ripple-related DOM was immediately present.
Some tests were doing things like:
assert that .MuiTouchRipple-root exists immediatelyor expecting ripple DOM synchronously after a click.
Those assumptions became brittle.
Depending on the test, we could:
- mock the ripple module
- wait for the ripple DOM with
waitFor - or migrate brittle tests to Vitest while working in that area
The important lesson here was:
Tests are part of the system you’re migrating.
They encode assumptions about implementation details.
When the implementation changes, some tests need to change with it.
Deprecation warnings stopped looking like noise
Another thing that changed for me personally was how I thought about deprecation warnings.
Before this migration, I could easily see a deprecation warning and think:
“It still works, so we’ll deal with it later.”
After working through a major version migration, I started seeing it differently.
A deprecation warning is basically a piece of future migration work that someone has already identified for you.
For example:
Divider light- old Autocomplete override APIs
- legacy ListItem patterns
- older styling APIs
- deprecated compound utility classes
The code may continue working today.
But now there’s a known cost attached to leaving it there.
So my new interpretation is:
A deprecation warning is scheduled work with a deadline attached to it.
It doesn’t necessarily mean “fix this today.”
It does mean “don’t pretend this problem doesn’t exist.”
What we deliberately left for later
A successful migration also requires knowing when to stop.
We did not migrate everything during the MUI v6 upgrade.
The main deferred areas were:
Legacy JSS
@mui/styles, makeStyles, and withStyles represented a much larger migration.
We wanted to remove them, but doing so wasn’t required to get Material v6 working.
That became a separate phase.
Grid modernization
Moving Grid to Grid2 wasn't blocking our v6 migration, so we didn't combine those changes.
Theme modernization
We also didn’t immediately move the entire theme setup to newer patterns such as CssVarsProvider and extendTheme.
Date pickers
Our existing date-picker version was already compatible, so there was no reason to introduce another major migration at the same time.
StrictMode
There were still JSS-related double-mount concerns, so StrictMode remained a separate piece of work until the styling migration could be addressed.
This was probably the clearest example of scope control during the project.
Not every outdated thing needs to be fixed because you’re already touching the codebase.
Sometimes the best migration decision is to leave something alone.
The process that ultimately worked
Looking back, the most useful thing wasn’t any individual MUI fix.
It was the sequence.
Our final process looked roughly like this:
1. Remove unused and blocking third-party dependencies
2. Pin react-is and align @types/react
3. Upgrade Material, icons, and Lab
4. Fix Vite deduplication and module-resolution issues
5. Run TypeScript / lint
6. Fix component-level breaking changes
7. Run the application
8. Fix runtime warnings and behavior changes
9. Run the test suite
10. Fix test assumptions around lazy ripple behavior
11. QA the important application flows
12. Keep remaining modernization work separateThat order matters.
We didn’t start by manually fixing hundreds of components.
We first made the dependency graph safer.
Then we got the build working.
Then we handled compile-time errors.
Then runtime behavior.
Then tests.
Then QA.
Each stage gave us a smaller and more understandable set of problems.
The tools that helped us audit the codebase
A large migration becomes much easier when you can quickly find patterns.
Some of the searches we used were simple repository-wide searches for things like:
rg 'LoadingButton' src/
rg '@mui/lab' src/
rg 'ListItemSecondaryAction' src/
rg 'ListboxComponent' src/
rg "@mui/system" src/
rg 'makeStyles|withStyles|createStyles' src/ --countThe point wasn’t that rg itself was special.
The point was to turn a vague migration requirement into a measurable question:
“How many places do we actually have to change?”
That is much easier to work with than:
“There are probably a lot of old MUI APIs somewhere.”
What I learned as a junior developer
This migration taught me much more than the differences between MUI v5 and v6.
1. Large migrations need a plan
The worst approach would have been:
bump everything → run the build → panic.
A checklist and a phased plan made the work manageable.
2. Scope control is an engineering skill
We didn’t need to migrate JSS, Grid, the theme, date pickers, and MUI itself all at once.
Knowing what not to change was just as important as knowing what to change.
3. There are multiple definitions of “done”
A migration can be:
type-correct
buildable
runnable
warning-free
testable
visually correctThose aren’t the same thing.
Passing tsc doesn't mean the migration is finished.
4. The bundler is part of your application
I didn’t think much about dependency deduplication, package entry points, or CJS vs ESM before this work.
Now I understand why a React dependency upgrade can turn into a Vite configuration problem.
5. Codemods are accelerators, not autopilot
They are excellent for repetitive transformations.
They don’t understand every custom wrapper, CSS dependency, or unusual component composition.
Always review the diff.
6. Deprecations are future migration work
A warning that doesn’t break today’s build can still become tomorrow’s blocker.
7. Fix problems at the right abstraction level
If ten Select components have the same problem, don’t necessarily fix ten components.
Look for the shared abstraction.
8. Keep the reasoning, not just the final diff
Six months later, I probably won’t remember every file that changed.
What will be useful is knowing:
- why we pinned
react-is - why Vite needed deduplication
- why a workaround was added and later removed
- why JSS was intentionally left alone
- why a particular component was restructured
That’s the kind of documentation that actually helps future migrations.
What I’d tell another developer starting a similar migration
If I had to reduce everything I learned into a practical playbook, it would be:
Start with an audit.
Know what MUI packages, legacy APIs, wrappers, and third-party integrations exist before changing versions.
Define the boundary.
Decide what the migration is responsible for and what belongs in a later phase.
Clean dependencies first.
Remove unused packages and upgrade or replace packages that are tightly coupled to the old version.
Use the official migration guide as a checklist.
Don’t rely only on compiler errors to tell you what changed.
Expect three types of problems.
Compile-time errors, runtime warnings, and visual or behavioral regressions.
Learn your build tool.
Duplicate packages, module resolution, and ESM/CJS issues can be just as important as component APIs.
Use codemods, but review everything.
Automation handles repetition. Humans handle context.
Test integrations.
A dependency upgraded alongside MUI can introduce bugs that look completely unrelated.
Don’t mix every modernization task into the version upgrade.
Getting onto the new version and modernizing the entire codebase are different projects.
Document the decisions.
Future-you will care more about why something was done than the exact line that changed.
The migration isn’t really over
Getting the application onto MUI v6 was one milestone, not the end of the work.
There is still a path ahead:
- removing legacy JSS
- modernizing the theme setup
- moving remaining components toward newer APIs
- revisiting Grid
- upgrading date pickers when appropriate
- eventually planning the move to newer MUI major versions
And that’s okay.
I used to think a successful migration meant reaching a point where everything was modern.
Now I think a successful migration means something slightly different:
The codebase is on the new version, the important behavior is stable, the remaining work is understood, and the next migration is easier than the last one.
That is what made this migration valuable for me.
I started the project thinking mostly about MUI components.
I finished it thinking about dependency graphs, bundlers, runtime behavior, testing assumptions, migration boundaries, and how to make a large codebase change without losing control of the system.
And that’s probably the biggest thing I learned:
Large migrations aren’t about changing everything. They’re about changing the right things, in the right order, while knowing why you’re changing them.







