Vite + React + TypeScript Integration Guide for MeoNode UI
Overview
A Vite app renders in the browser by default, so there is no SSR boundary to get right and no style registry to mount — the setup is mostly wiring providers in the right order and knowing the one HMR rule that catches people out. If you do add server rendering, there is still no registry; see server-side rendering. This guide covers the plugin configuration, where the theme provider and portal host go, and the sub-component pattern Vite's fast refresh needs.
New to the library itself? Start with the overview — this page assumes you know what a node is and covers only what Vite adds.
Installation & Setup
Create a new Vite project with React and TypeScript support:
# Create a new Vite app with React and TypeScript npm create vite@latest my-app -- --template react-ts # Navigate to your project directory cd my-app # Install MeoNode UI and its peer dependencies npm install @meonode/ui react react-dom @emotion/react @emotion/cache # This guide also uses Redux Toolkit for app state, and @meonode/mui for a # couple of Material components. Both are optional — skip them if you are not # using them, and skip the sections that reference them. npm install @reduxjs/toolkit react-redux @meonode/mui
Vite Configuration
Update your Vite configuration to support Emotion (MeoNode UI's CSS engine):
import { defineConfig } from 'vite' import react, { reactCompilerPreset } from '@vitejs/plugin-react' import { visualizer } from 'rollup-plugin-visualizer' import * as path from 'node:path' import { dependencies } from './package.json' import { imagetools } from 'vite-imagetools' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import babel from '@rolldown/plugin-babel' function renderChunks(deps: Record<string, string>) { const chunks: Record<string, string[]> = { react: ['react'] } Object.keys(deps).forEach(key => { if (['react', 'react-dom'].includes(key)) return chunks[key] = [key] }) return chunks } function manualChunks(id: string) { const chunks = renderChunks(dependencies) for (const [name, modules] of Object.entries(chunks)) { if (modules.some(m => id.includes(`/node_modules/${m}/`))) return name } } // https://vite.dev/config/ export default defineConfig({ mode: process.env.NODE_ENV, plugins: [ react(), babel({ presets: [reactCompilerPreset()] }), imagetools(), ViteImageOptimizer(), visualizer({ open: true, sourcemap: true, filename: 'bundle_report.html' }), ], build: { minify: 'esbuild', cssMinify: 'esbuild', sourcemap: true, rollupOptions: { output: { manualChunks, }, }, }, resolve: { alias: [{ find: '@src', replacement: path.resolve(__dirname, 'src') }], }, })
Optional: build-time compiler
@meonode/compiler is an SWC plugin that moves prop
classification and theme.* resolution out of every render and into your build.
It also marks call sites whose children came from an expression, which is what
lets React report an unkeyed list — the runtime cannot tell a .map() from
hand-written siblings on its own.
Optional, and rendering is identical with or without it. Removing it does remove the missing-key reports, since nothing else records that distinction.
npm install --save-dev @meonode/compiler
It is an SWC plugin, so it needs @vitejs/plugin-react-swc rather than the
Babel-based @vitejs/plugin-react used in the configuration above. The two are
alternatives, not additions — swap the import and drop the Babel plugin:
npm uninstall @vitejs/plugin-react @rolldown/plugin-babel npm install --save-dev @vitejs/plugin-react-swc
// vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react-swc' export default defineConfig({ plugins: [react({ plugins: [['@meonode/compiler', {}]] })], })
If you would rather keep the Babel plugin — for the React Compiler preset, say —
skip @meonode/compiler entirely. Nothing else in this guide depends on it.
Measured against @meonode/ui@2.x: ~1.7x faster node construction in
isolation, ~1.06x on a client render including React, Emotion and the DOM.
A ~1.6x figure you may have seen was measured against @meonode/ui@1.x, most of
which was stable-key hashing that 2.0.0 removed from the runtime entirely.
See the compiler guide for what compiles, what is left untouched, and how to verify the plugin actually ran.
Theme Configuration
Theme Objects
Create theme objects structure with mode and system properties:
Shared scale tokens live in one file so both themes spread the same set. Every
theme.* string used in this guide resolves against it — a token the active
theme does not define emits a var() that resolves to nothing, and the browser
drops the whole declaration, so keep this in step with what your components
reference.
const baseThemeSystem = { text: { sm: '0.875rem', md: '1rem', lg: '1.125rem', xl: '1.25rem', '2xl': '1.5rem', '3xl': '1.875rem', '4xl': '2.25rem', }, spacing: { xs: '0.25rem', sm: '0.5rem', md: '1rem', lg: '1.5rem', xl: '2rem', '2xl': '3rem', }, radius: { sm: '2px', md: '4px', lg: '8px', full: '9999px', }, } export default baseThemeSystem
import baseThemeSystem from './baseThemeSystem' import type { Theme } from '@meonode/ui' const lightTheme: Theme = { mode: 'light', system: { ...baseThemeSystem, primary: { default: '#000000', content: '#ffffff', muted: '#bdbdbd', hover: '#222222', }, secondary: { default: '#370900', content: '#fed7a7', muted: '#a67c6b', hover: '#5a1400', }, accent: { default: '#8c3f27', content: '#fed7a7', muted: '#b97a5a', hover: '#a44a2e', }, neutral: { default: '#c93400', content: '#fff7ed', muted: '#e0a899', hover: '#e04a1a', }, base: { default: '#fff7ed', medium: '#feebd3', deep: '#fed7a7', content: '#7a3205', muted: '#f5e1d3', hover: '#ffe7c2', }, success: { default: '#2E7D32', content: '#fef3c5', muted: '#81c784', hover: '#388e3c', }, warning: { default: '#F9A825', content: '#fffde7', muted: '#ffe082', hover: '#ffb300', }, error: { default: '#D32F2F', content: '#ffebee', muted: '#e57373', hover: '#e53935', }, danger: { default: '#C62828', content: '#ffcdd2', muted: '#ef9a9a', hover: '#d32f2f', }, info: { default: '#0288D1', content: '#e1f5fe', muted: '#81d4fa', hover: '#039be5', }, shadow: { default: '0 4px 12px rgba(0, 0, 0, 0.1)', sm: '0 1px 2px rgba(0, 0, 0, 0.05)', md: '0 2px 6px rgba(0, 0, 0, 0.08)', lg: '0 8px 16px rgba(0, 0, 0, 0.12)', xl: '0 12px 24px rgba(0, 0, 0, 0.15)', '2xl': '0 16px 32px rgba(0, 0, 0, 0.2)', }, }, } export default lightTheme
Redux Toolkit Integration (Optional)
MeoNode UI manages theming through Context, so Redux is only needed for your application's business logic — never for theme state.
Store Configuration (Simplified)
import { configureStore } from '@reduxjs/toolkit' import { useDispatch, useSelector } from 'react-redux' import appSlice from '@src/redux/slice/app.slice.ts' export const store = configureStore({ reducer: { appReducer: appSlice, // Add your reducers here }, }) export type RootState = ReturnType<typeof store.getState> export type AppDispatch = typeof store.dispatch export const useAppDispatch = useDispatch.withTypes<AppDispatch>() export const useAppSelector = useSelector.withTypes<RootState>()
Provider Setup
Simplified Providers Component
Create a provider component using MeoNode UI's built-in theme and portal management:
import type { ThemeScriptConfig } from '@meonode/ui' // One description of this app's themes. The provider renders from it, and the // snippet in `index.html` below reads the same names and storage key. export const themeConfig = { modes: ['light', 'dark'], defaultMode: 'light', defaultPreference: 'system', system: { light: 'light', dark: 'dark' }, } satisfies ThemeScriptConfig
import { store } from '@src/redux/store' import { type Children, Node, ThemeProvider, PortalProvider, PortalHost } from '@meonode/ui' import { Provider as ReduxProvider } from 'react-redux' import { SnackbarProvider } from 'notistack' import { themeConfig } from '@src/constants/themes/config.ts' import { themeTokens } from '@src/constants/themes/tokens.ts' interface WrappersProps { children: Children } export const Wrapper = ({ children }: WrappersProps) => { return Node(ReduxProvider, { store, // No `localStorage` read here. The provider owns the stored preference and // stamps `data-theme` on `<html>`; reading it separately is a second source // of truth that can disagree with the first. children: ThemeProvider({ ...themeConfig, tokens: themeTokens, children: Node(SnackbarProvider, { children: PortalProvider({ children: Array.isArray(children) ? [...children, PortalHost()] : [children, PortalHost()], }), }), }), }).render() }
Tokens are var() references and the palettes live in CSS, keyed by the attribute:
export const themeTokens = { base: { default: 'var(--base-default)', content: 'var(--base-content)' }, primary: { default: 'var(--primary-default)', content: 'var(--primary-content)' }, }
:root { --base-default: #ffffff; --base-content: #1a1a1a; } [data-theme='dark'] { --base-default: #0a0a0f; --base-content: #f0f0f5; }
Applying the mode before the first paint
themeScript() returns a node for a server-rendered <head>, so it is for frameworks like Next. A client-rendered Vite app has no server render: index.html is a static shell, and anything React does happens after the first paint. If you leave it at that, a reader who chose dark still sees the light shell for as long as the bundle takes to load.
Put the equivalent in index.html, before your stylesheet — it has to read the same storage key and write the same attribute the provider does:
<!doctype html> <html lang="en"> <head> <script> ;(function () { try { var c = { modes: ['light', 'dark'], default: 'light', preference: 'system', system: { light: 'light', dark: 'dark' } } var p = c.preference try { var v = localStorage.getItem('theme') if (typeof v === 'string' && v) p = v } catch (e) {} if (p !== 'system' && c.modes.indexOf(p) < 0) p = c.default var t = p if (p === 'system') { t = c.system.light try { if (matchMedia('(prefers-color-scheme: dark)').matches) t = c.system.dark } catch (e) {} } document.documentElement.setAttribute('data-theme', t) document.documentElement.setAttribute('data-theme-preference', p) } catch (e) {} })() </script> <!-- stylesheets after the script: a script following a stylesheet cannot run until it has loaded --> </head> </html>
Every failure path above ends with no attribute written, which is deliberate — a reader whose storage is blocked gets whatever :root defines rather than an unstyled page. That is why the light palette belongs on :root with [data-theme='…'] as the override, not only under the attribute.
The provider seeds itself from the attribute this writes, so the two agree. Keep the mode names and storage key in step with themeConfig; nothing can check that for you across the HTML boundary.
Router Setup
Route Component
import { createBrowserRouter, type RouteObject, RouterProvider } from 'react-router' import { lazy, Suspense } from 'react' import { Absolute, Center, Node, type NodeElementType } from '@meonode/ui' import { CircularProgress } from '@meonode/mui' type RouteType = Omit<RouteObject, 'children' | 'element'> & { element?: NodeElementType children?: RouteType[] } const routes: RouteType[] = [ { path: '/', element: lazy(() => import('@src/pages/App.tsx')), }, ] const Fallback = Absolute({ top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'theme.base', children: Center({ height: '100%', children: CircularProgress({ size: 50, color: 'theme.base.content' }) }), }).render() const wrapElement = (routes: RouteType[]): RouteObject[] => { return routes.map(route => ({ ...route, element: route.element ? Node(Suspense, { fallback: Fallback, children: Node(route.element) }).render() : undefined, children: route.children ? wrapElement(route.children) : undefined, })) as RouteObject[] } const router = createBrowserRouter(wrapElement(routes)) const Routes = () => Node(RouterProvider, { router }) export default Routes
Application Components
App Component
import { Center, Column, Row, H1, H2, Button, Text, Div, usePortal, useTheme, type PortalLayerProps } from '@meonode/ui' import { useState } from 'react' const App = () => { // `mode` is read in the handler below, never rendered into markup. const { mode, setMode } = useTheme() const [counter, setCounter] = useState(0) const portal = usePortal({ counter, setCounter }) return Center({ minHeight: '100dvh', backgroundColor: 'theme.base', color: 'theme.base.content', children: Column({ gap: 'theme.spacing.lg', textAlign: 'center', children: [ H1('Vite + MeoNode UI', { fontSize: 'theme.text.4xl' }), Row({ gap: 'theme.spacing.md', children: [ Button('🎯 Open Demo Portal', { onClick: () => portal.open(InteractiveDemo) }), Button('Toggle Theme', { onClick: () => setMode(mode === 'dark' ? 'light' : 'dark') }), ] }) ], }), }).render() } // Interactive demo modal component const InteractiveDemo = ({ data, close }: PortalLayerProps) => { return Center({ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)', zIndex: 1000, onClick: e => e.target === e.currentTarget && close(), children: Column({ backgroundColor: 'theme.base', padding: 'theme.spacing.2xl', borderRadius: '20px', gap: 'theme.spacing.lg', children: [ H2('Interactive Portal'), Text(`Counter: ${data.counter}`), Row({ gap: 'theme.spacing.md', children: [ Button('-', { onClick: () => data.setCounter(c => c - 1) }), Button('+', { onClick: () => data.setCounter(c => c + 1) }), ] }), Button('Close', { onClick: close }) ] }) }).render() } export default App
Main Entry Point
Updated main.tsx
import { StrictMode } from 'react' import { Node } from '@meonode/ui' import Routes from '@src/routes' import { Wrapper } from '@src/components/Wrapper.ts' import '@src/assets/global.css' import { render } from '@meonode/ui/client' const App = Node(StrictMode, { children: Node(Wrapper, { children: Routes() }) }) render(App, document.getElementById('root')!)
Server-Side Rendering (Optional)
If you render on the server with Vite's SSR mode, the server entry renders the
app to HTML and the client entry hydrates it. Both render the same root — here a
Root component returning your providers and pages, as main.tsx does, with a
router that can run on the server in place of createBrowserRouter. Hydrate with
React's hydrateRoot and the node's .render(): render() from
@meonode/ui/client creates a new root and does not hydrate.
import { renderToString } from 'react-dom/server' import { Node } from '@meonode/ui' import Root from '@src/Root' export function render() { return renderToString(Node(Root).render()) }
import { hydrateRoot } from 'react-dom/client' import { Node } from '@meonode/ui' import Root from '@src/Root' hydrateRoot(document.getElementById('root')!, Node(Root).render())
There is no style registry to mount. The server writes each style into the HTML
it renders, and the client hydrates with the same class names, with or without an
Emotion CacheProvider; renderToPipeableStream behaves the same way.
Since 3.1.0 this includes components built with createNode and MUI components
given styles; on 3.0 their class names differed between server and client, and
React reported that the attributes did not match.
Best Practices for Vite Integration
- Context-Based Theming: Always wrap your app with
ThemeProvider(usually via aWrappercomponent) to ensure theme context is available everywhere. Pair it with theindex.htmlsnippet above so the mode is applied before the first paint rather than after the bundle loads. - Never render markup from
mode: A sun/moon icon, acheckedstate or a dark-only element rendered frommodedisagrees with what the pre-paint script already applied, and it is wrong for the whole time before React mounts. Drive those differences from CSS keyed off[data-theme='…'], or gate them onhydratedfromuseTheme(). - Portal System: Include
PortalProviderandPortalHostin your root wrapper. Portals opened viausePortalwill automatically inherit the application context (theme, redux, etc.). - Functional Components: Portal content should be regular functional components that return
.render()and acceptPortalLayerProps. - Tree Shaking: Vite's ES modules approach naturally supports tree shaking for MeoNode UI components.
- Persistence: Leave it to the provider.
setModeandsetPreferencewrite the reader's choice tolocalStorageand stamp<html>; a second read in your ownWrapperis a second source of truth that can disagree with the first.
Additional Resources
- Live Example: Check out the meonode-vite repository.
- Redux Toolkit: Official Documentation.
- Vite: Official Documentation.
Related FAQ
Why is Hot Module Replacement (HMR) not working for my sub-components?
In Vite, ensure sub-components follow stable component boundaries and expected file patterns. Hook-using components should be wrapped correctly to avoid stale updates.
How do I use existing JSX components or JSX libraries with MeoNode UI?
Use Node() wrappers (or package-level wrappers like @meonode/mui) so JSX-based libraries remain compatible with your MeoNode composition style.
More details: /docs/getting-started/faq
On this page
- Vite + React + TypeScript Integration Guide for MeoNode UI