This guide covers setting up Material-UI with TanStack Router, including proper TypeScript integration and component composition patterns.
Time Required: 45-60 minutes
Difficulty: Intermediate
Prerequisites: Existing TanStack Router project
npm install @mui/material @emotion/react @emotion/styled @mui/icons-material
Optional: Add date picker support
npm install @mui/x-date-pickers dayjs
Create a theme provider that works with TanStack Router:
// src/components/theme-provider.tsx
import { ThemeProvider, createTheme } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import { ReactNode } from 'react'
const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
},
components: {
// Customize components for router integration
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none', // More modern button styling
},
},
},
MuiLink: {
styleOverrides: {
root: {
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline',
},
},
},
},
},
})
interface MuiThemeProviderProps {
children: ReactNode
}
export function MuiThemeProvider({ children }: MuiThemeProviderProps) {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
)
}
Wrap your application with the MUI theme provider:
// src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
import { MuiThemeProvider } from '@/components/theme-provider'
export const Route = createRootRoute({
component: () => (
<MuiThemeProvider>
<Outlet />
<TanStackRouterDevtools />
</MuiThemeProvider>
),
})
MUI Link components require special handling for TanStack Router's type system:
// src/components/ui/mui-router-link.tsx
import { createLink } from '@tanstack/react-router'
import { Link as MuiLink, type LinkProps } from '@mui/material/Link'
import { forwardRef } from 'react'
// Create a router-compatible MUI Link with full type safety
export const RouterLink = createLink(
forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
return <MuiLink ref={ref} {...props} />
}),
)
// src/components/ui/mui-router-button.tsx
import { createLink } from '@tanstack/react-router'
import { Button, type ButtonProps } from '@mui/material/Button'
import { forwardRef } from 'react'
// Create a router-compatible MUI Button
export const RouterButton = createLink(
forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
return <Button ref={ref} component="button" {...props} />
}),
)
// src/components/ui/mui-router-fab.tsx
import { createLink } from '@tanstack/react-router'
import { Fab, type FabProps } from '@mui/material/Fab'
import { forwardRef } from 'react'
// Router-compatible Floating Action Button
export const RouterFab = createLink(
forwardRef<HTMLButtonElement, FabProps>((props, ref) => {
return <Fab ref={ref} {...props} />
}),
)
// src/components/navigation/mui-nav-tabs.tsx
import { useMatchRoute } from '@tanstack/react-router'
import { Tabs, Tab, type TabsProps } from '@mui/material'
import { RouterLink } from '@/components/ui/mui-router-link'
interface NavTab {
label: string
to: string
value: string
icon?: React.ReactNode
}
interface MuiNavTabsProps extends Omit<TabsProps, 'value' | 'onChange'> {
tabs: NavTab[]
}
export function MuiNavTabs({ tabs, ...tabsProps }: MuiNavTabsProps) {
const matchRoute = useMatchRoute()
// Find active tab based on current route
const activeTab =
tabs.find((tab) => matchRoute({ to: tab.to, fuzzy: true }))?.value || false
return (
<Tabs value={activeTab} {...tabsProps}>
{tabs.map((tab) => (
<Tab
key={tab.value}
label={tab.label}
value={tab.value}
icon={tab.icon}
component={RouterLink}
to={tab.to}
sx={{
'&.Mui-selected': {
fontWeight: 'bold',
},
}}
/>
))}
</Tabs>
)
}
// src/components/navigation/mui-nav-drawer.tsx
import { useMatchRoute } from '@tanstack/react-router'
import {
Drawer,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Typography,
Box,
type DrawerProps,
} from '@mui/material'
import { RouterLink } from '@/components/ui/mui-router-link'
interface DrawerItem {
label: string
to: string
icon?: React.ReactNode
}
interface MuiNavDrawerProps extends Omit<DrawerProps, 'children'> {
items: DrawerItem[]
title?: string
}
export function MuiNavDrawer({
items,
title,
...drawerProps
}: MuiNavDrawerProps) {
const matchRoute = useMatchRoute()
return (
<Drawer {...drawerProps}>
<Box sx={{ width: 250 }} role="presentation">
{title && (
<Typography
variant="h6"
sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}
>
{title}
</Typography>
)}
<List>
{items.map((item) => {
const isActive = matchRoute({ to: item.to, fuzzy: true })
return (
<ListItem key={item.to} disablePadding>
<ListItemButton
component={RouterLink}
to={item.to}
selected={isActive}
sx={{
'&.Mui-selected': {
backgroundColor: 'primary.main',
color: 'primary.contrastText',
'&:hover': {
backgroundColor: 'primary.dark',
},
},
}}
>
{item.icon && <ListItemIcon>{item.icon}</ListItemIcon>}
<ListItemText primary={item.label} />
</ListItemButton>
</ListItem>
)
})}
</List>
</Box>
</Drawer>
)
}
// src/components/navigation/mui-app-bar.tsx
import { useState } from 'react'
import {
AppBar,
Toolbar,
Typography,
IconButton,
Menu,
MenuItem,
Box,
} from '@mui/material'
import { Menu as MenuIcon, AccountCircle } from '@mui/icons-material'
import { RouterButton, RouterLink } from '@/components/ui/mui-router-link'
import { MuiNavDrawer } from './mui-nav-drawer'
interface AppBarItem {
label: string
to: string
icon?: React.ReactNode
}
interface MuiAppBarProps {
title: string
navigationItems: AppBarItem[]
userMenuItems?: AppBarItem[]
}
export function MuiAppBar({
title,
navigationItems,
userMenuItems,
}: MuiAppBarProps) {
const [drawerOpen, setDrawerOpen] = useState(false)
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null)
const handleUserMenuClick = (event: React.MouseEvent<HTMLElement>) => {
setUserMenuAnchor(event.currentTarget)
}
const handleUserMenuClose = () => {
setUserMenuAnchor(null)
}
return (
<>
<AppBar position="static">
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={() => setDrawerOpen(true)}
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
<RouterLink to="/" color="inherit" underline="none">
{title}
</RouterLink>
</Typography>
{/* Desktop Navigation */}
<Box sx={{ display: { xs: 'none', md: 'flex' }, mr: 2 }}>
{navigationItems.map((item) => (
<RouterButton
key={item.to}
to={item.to}
color="inherit"
startIcon={item.icon}
sx={{ ml: 1 }}
>
{item.label}
</RouterButton>
))}
</Box>
{/* User Menu */}
{userMenuItems && (
<>
<IconButton color="inherit" onClick={handleUserMenuClick}>
<AccountCircle />
</IconButton>
<Menu
anchorEl={userMenuAnchor}
open={Boolean(userMenuAnchor)}
onClose={handleUserMenuClose}
>
{userMenuItems.map((item) => (
<MenuItem
key={item.to}
component={RouterLink}
to={item.to}
onClick={handleUserMenuClose}
>
{item.label}
</MenuItem>
))}
</Menu>
</>
)}
</Toolbar>
</AppBar>
{/* Mobile Navigation Drawer */}
<MuiNavDrawer
items={navigationItems}
title="Navigation"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
/>
</>
)
}
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import {
Container,
Typography,
Box,
Card,
CardContent,
CardActions,
Chip,
Stack,
} from '@mui/material'
import { Edit, Delete, ArrowBack } from '@mui/icons-material'
import { RouterButton, RouterLink } from '@/components/ui/mui-router-link'
export const Route = createFileRoute('/posts/$postId')({
component: PostPage,
})
function PostPage() {
const { postId } = Route.useParams()
return (
<Container maxWidth="md" sx={{ py: 4 }}>
{/* Breadcrumb Navigation */}
<Box sx={{ mb: 3 }}>
<RouterLink
to="/posts"
color="primary"
sx={{ display: 'flex', alignItems: 'center', mb: 2 }}
>
<ArrowBack sx={{ mr: 1 }} />
Back to Posts
</RouterLink>
</Box>
{/* Post Content */}
<Card>
<CardContent>
<Typography variant="h4" component="h1" gutterBottom>
Post {postId}
</Typography>
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Chip label="React" color="primary" size="small" />
<Chip label="TypeScript" color="secondary" size="small" />
</Stack>
<Typography variant="body1" paragraph>
This is the content of post {postId}. It demonstrates how
Material-UI components work seamlessly with TanStack Router.
</Typography>
</CardContent>
<CardActions>
<RouterButton
to="/posts/$postId/edit"
params={{ postId }}
variant="contained"
startIcon={<Edit />}
size="small"
>
Edit Post
</RouterButton>
<RouterButton
to="/posts/$postId/delete"
params={{ postId }}
variant="outlined"
color="error"
startIcon={<Delete />}
size="small"
>
Delete Post
</RouterButton>
</CardActions>
</Card>
</Container>
)
}
// src/routes/_layout.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { Box } from '@mui/material'
import { Home, Article, Info, Contact } from '@mui/icons-material'
import { MuiAppBar } from '@/components/navigation/mui-app-bar'
export const Route = createFileRoute('/_layout')({
component: LayoutComponent,
})
const navigationItems = [
{ label: 'Home', to: '/', icon: <Home /> },
{ label: 'Posts', to: '/posts', icon: <Article /> },
{ label: 'About', to: '/about', icon: <Info /> },
{ label: 'Contact', to: '/contact', icon: <Contact /> },
]
const userMenuItems = [
{ label: 'Profile', to: '/profile' },
{ label: 'Settings', to: '/settings' },
{ label: 'Logout', to: '/logout' },
]
function LayoutComponent() {
return (
<Box sx={{ flexGrow: 1 }}>
<MuiAppBar
title="My App"
navigationItems={navigationItems}
userMenuItems={userMenuItems}
/>
<Box component="main" sx={{ mt: 2 }}>
<Outlet />
</Box>
</Box>
)
}
Problem: TypeScript errors when using MUI components with TanStack Router props.
Solution: Always use createLink for proper typing:
// ❌ This will cause TypeScript errors
const BadButton = (props: any) => <Button {...props} />
// ✅ This provides full type safety
export const RouterButton = createLink(
forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
return <Button ref={ref} component="button" {...props} />
}),
)
Problem: MUI styles conflict with other libraries or custom styles.
Solutions:
Use MUI's emotion cache:
import { CacheProvider } from '@emotion/react'
import createCache from '@emotion/cache'
const cache = createCache({
key: 'mui',
prepend: true,
})
export function App() {
return (
<CacheProvider value={cache}>
<MuiThemeProvider>{/* Your app */}</MuiThemeProvider>
</CacheProvider>
)
}
Increase CSS specificity:
const StyledButton = styled(Button)(({ theme }) => ({
'&.router-active': {
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
},
}))
Problem: MUI theme changes don't apply to router-created components.
Solution: Ensure theme provider wraps the entire app:
// ❌ Theme provider inside routes won't work for navigation
export const Route = createFileRoute('/some-route')({
component: () => (
<ThemeProvider theme={theme}>
<SomeComponent />
</ThemeProvider>
),
})
// ✅ Theme provider at root level
export const Route = createRootRoute({
component: () => (
<ThemeProvider theme={theme}>
<Outlet />
</ThemeProvider>
),
})
Problem: Bundle size or runtime performance issues.
Solutions:
Use tree shaking:
// ✅ Import only what you need
import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
// ❌ Avoid importing everything
import { Button, TextField } from '@mui/material'
Use dynamic imports for heavy components:
import { lazy, Suspense } from 'react'
import { CircularProgress } from '@mui/material'
const DataGrid = lazy(() =>
import('@mui/x-data-grid').then((module) => ({
default: module.DataGrid,
})),
)
function MyComponent() {
return (
<Suspense fallback={<CircularProgress />}>
<DataGrid {...props} />
</Suspense>
)
}
Before deploying your MUI + TanStack Router app: