• Getting Started
    • Overview
    • Why Without JSX?
    • Installation
    • Usage
    • Styling
    • Theming
    • Portal System
    • Rules & Patterns
    • Framework Integration
    • Compiler
    • FAQ
    • Release Notes
  • MUI Integration
  • Components
  • Hooks

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.


Vite

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

  1. Context-Based Theming: Always wrap your app with ThemeProvider (usually via a Wrapper component) to ensure theme context is available everywhere. Pair it with the index.html snippet above so the mode is applied before the first paint rather than after the bundle loads.
  2. Never render markup from mode: A sun/moon icon, a checked state or a dark-only element rendered from mode disagrees 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 on hydrated from useTheme().
  3. Portal System: Include PortalProvider and PortalHost in your root wrapper. Portals opened via usePortal will automatically inherit the application context (theme, redux, etc.).
  4. Functional Components: Portal content should be regular functional components that return .render() and accept PortalLayerProps.
  5. Tree Shaking: Vite's ES modules approach naturally supports tree shaking for MeoNode UI components.
  6. Persistence: Leave it to the provider. setMode and setPreference write the reader's choice to localStorage and stamp <html>; a second read in your own Wrapper is a second source of truth that can disagree with the first.

Additional Resources


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