MeoNode UI
  • Getting Started
    • Overview
    • Installation
    • Usage
    • Styling
    • Theming
    • Rules & Patterns
    • Framework Integration
    • FAQ
    • Release Notes
  • Components

Next.js Integration Guide for MeoNode UI

Overview

@meonode/ui is a modern, type-safe React UI library designed for seamless integration with popular frameworks, especially Next.js. This guide provides a comprehensive, step-by-step walkthrough for setting up a Next.js project with MeoNode UI, demonstrating real-world integration patterns with the new Context-based theming system.

⚠️ Important: This guide is for MeoNode UI v0.3.0+ which introduced breaking changes to the theme system. If you're upgrading from v0.2.x, please see the Migration Guide.


Next.js Next.js Integration

Installation & Setup

Begin your project by using the recommended Next.js CLI to set up a new application. This ensures your project is pre-configured with the latest best practices.

# Create a new Next.js app with TypeScript support
npx create-next-app@latest my-app --typescript

# Navigate to your project directory
cd my-app

# Install the core MeoNode UI library
yarn add @meonode/ui @emotion/cache

Next.js Configuration (Optional)

Configure Emotion's CSS-in-JS functionality. This is optional, as the library will still function without it, but enabling it ensures full support for advanced CSS-in-JS features and React Server Components compatibility.

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  // Enables Emotion's CSS-in-JS features (optional)
  compiler: {
    emotion: true,
  },
  // Optimizes module imports to improve build performance
  experimental: {
    optimizePackageImports: ['@meonode/ui'],
  },
}

export default nextConfig

Theme Configuration (src/constants/themes/)

Define your light and dark themes following the new structure required by MeoNode UI. Each theme must include a mode and a system object containing your design tokens.

import { Theme } from '@meonode/ui'

const lightTheme: Theme = {
  mode: 'light',
  system: {
    primary: {
      default: '#2196F3',
      content: '#FFFFFF',
    },
    secondary: {
      default: '#9C27B0',
      content: '#FFFFFF',
    },
    accent: {
      default: '#FF9800',
      content: '#000000',
    },
    base: {
      default: '#FFFFFF',
      content: '#1A1A1A',
    },
    surface: {
      default: '#F5F5F5',
      content: '#333333',
    },
    success: {
      default: '#4CAF50',
      content: '#FFFFFF',
    },
    warning: {
      default: '#FF9800',
      content: '#000000',
    },
    error: {
      default: '#F44336',
      content: '#FFFFFF',
    },
    spacing: {
      xs: '4px',
      sm: '8px',
      md: '16px',
      lg: '24px',
      xl: '32px',
      '2xl': '48px',
    },
    breakpoint: {
      xs: '480px',
      sm: '640px',
      md: '768px',
      lg: '1024px',
      xl: '1280px',
      '2xl': '1536px',
    },
    text: {
      xs: '0.75rem',
      sm: '0.875rem',
      base: '1rem',
      lg: '1.125rem',
      xl: '1.25rem',
      '2xl': '1.5rem',
      '3xl': '1.875rem',
      '4xl': '2.25rem',
    },
  },
}

export default lightTheme
import { Theme } from '@meonode/ui'

const darkTheme: Theme = {
  mode: 'dark',
  system: {
    primary: {
      default: '#2196F3',
      content: '#FFFFFF',
    },
    secondary: {
      default: '#9C27B0',
      content: '#FFFFFF',
    },
    accent: {
      default: '#FF9800',
      content: '#000000',
    },
    base: {
      default: '#121212',
      content: '#E0E0E0',
    },
    surface: {
      default: '#1E1E1E',
      content: '#FFFFFF',
    },
    success: {
      default: '#4CAF50',
      content: '#FFFFFF',
    },
    warning: {
      default: '#FF9800',
      content: '#000000',
    },
    error: {
      default: '#F44336',
      content: '#FFFFFF',
    },
    spacing: {
      xs: '4px',
      sm: '8px',
      md: '16px',
      lg: '24px',
      xl: '32px',
      '2xl': '48px',
    },
    breakpoint: {
      xs: '480px',
      sm: '640px',
      md: '768px',
      lg: '1024px',
      xl: '1280px',
      '2xl': '1536px',
    },
    text: {
      xs: '0.75rem',
      sm: '0.875rem',
      base: '1rem',
      lg: '1.125rem',
      xl: '1.25rem',
      '2xl': '1.5rem',
      '3xl': '1.875rem',
      '4xl': '2.25rem',
    },
  },
}

export default darkTheme

Store (src/redux/store.ts)

This file sets up a singleton Redux store, making it accessible from both the server and client in a Next.js environment. It defines a slice for theme management and creates typed hooks for a better developer experience.

// Import necessary Redux Toolkit and React-Redux utilities
import { configureStore, Store, UnknownAction } from '@reduxjs/toolkit'
import { Provider, useDispatch, useSelector } from 'react-redux'
import { createNode } from '@meonode/ui'
import { setupListeners } from '@reduxjs/toolkit/query'
import appSlice, { AppState, setIsMobile } from '@src/redux/slice/app.slice'

export interface RootState {
  app: AppState
}

let globalStore: Store<RootState, UnknownAction> | undefined

export const initializeStore = (preloadedState?: RootState): Store<RootState, UnknownAction> => {
  if (!globalStore) {
    globalStore = configureStore({
      reducer: {
        app: appSlice,
      },
      preloadedState,
    })
    setupListeners(globalStore.dispatch)
  } else if (preloadedState) {
    globalStore.dispatch(setIsMobile(preloadedState.app.isMobile))
  }

  return globalStore
}

export type AppDispatch = ReturnType<typeof initializeStore>['dispatch']

export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

export const ReduxProviderWrapper = createNode(Provider)

Wrapper Components (src/components/Wrapper.ts)

This file defines the Wrapper component that wraps the entire app with Redux and Theme providers. It also includes a PortalWrapper component for modals and overlays, ensuring they have access to the theme context.

'use client'
import { Children, Node, Theme, ThemeProvider as MeoThemeProvider } from '@meonode/ui'
import { initializeStore, ReduxProviderWrapper, RootState } from '@src/redux/store'
import { StrictMode, useEffect, useMemo, useState } from 'react'
import { CssBaseline } from '@meonode/mui'
import darkTheme from '@src/constants/themes/darkTheme'
import lightTheme from '@src/constants/themes/lightTheme'
import { StyleRegistry } from '@meonode/ui/nextjs-registry'

const ThemeProvider = ({ children, isPortal, theme }: { children?: Children; isPortal?: boolean; theme?: Theme }) => {
  const [loadedTheme, setLoadedTheme] = useState<Theme | undefined>(theme)

  useEffect(() => {
    if (!theme) {
      const stored = localStorage.getItem('theme')

      if (!stored) {
        const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
        setLoadedTheme(isDark ? darkTheme : lightTheme)
      } else {
        setLoadedTheme(stored === 'dark' ? darkTheme : lightTheme)
      }
    }
  }, [theme])

  useEffect(() => {
    if (isPortal) {
      const handleStorageChange = (e: StorageEvent) => {
        if (e.key === 'theme') {
          setLoadedTheme(e.newValue === 'dark' ? darkTheme : lightTheme)
        }
      }

      window.addEventListener('storage', handleStorageChange)

      return () => {
        window.removeEventListener('storage', handleStorageChange)
      }
    }
  }, [isPortal])

  return MeoThemeProvider({ theme: loadedTheme, children }).render()
}

export const Wrapper = ({
  preloadedState,
  initialThemeMode,
  children,
  isPortal = false,
}: {
  preloadedState?: RootState
  initialThemeMode?: Theme['mode']
  children?: Children
  isPortal?: boolean
}) => {
  const initialStore = useMemo(() => initializeStore(preloadedState), [preloadedState])
  const theme = useMemo(() => {
    switch (initialThemeMode) {
      case 'dark':
        return darkTheme
      case 'light':
      default:
        return lightTheme
    }
  }, [initialThemeMode])

  return Node(StrictMode, {
    children: StyleRegistry({
      children: [
        CssBaseline(),
        ReduxProviderWrapper({
          store: initialStore,
          children: ThemeProvider({ theme, isPortal, children }),
        }),
      ],
    }),
  }).render()
}

export const PortalWrapper = Node(StrictMode, {
  children: Node(Wrapper, { isPortal: true }),
})

App Router Integration (src/app/layout.ts)

This is the main layout file for Next.js using the App Router. It sets up global styles, fonts, and the ProvidersWrapper to ensure the entire app has access to Redux and the theme context.

import type { Metadata } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import '@src/app/globals.css'
import { Html, Body, Node } from '@meonode/ui'
import { cookies, headers } from 'next/headers'
import darkTheme from '@src/constants/themes/darkTheme'
import lightTheme from '@src/constants/themes/lightTheme'
import { ReactNode } from 'react'
import { StyleRegistry } from '@meonode/ui/nextjs-registry'
import { RootState } from '@src/redux/store'
import { Wrapper } from '@src/components/Wrapper'
import { userAgent } from 'next/server'

const geistSans = Geist({
  variable: '--font-geist-sans',
  subsets: ['latin'],
})

const geistMono = Geist_Mono({
  variable: '--font-geist-mono',
  subsets: ['latin'],
})

export const metadata: Metadata = {
  title: 'MeoNode UI with Next.js',
  description: 'Type-safe React UI without JSX',
}

export default async function RootLayout({ children }: { children: ReactNode }) {
  const reqHeaders = await headers()
  const ua = userAgent({ headers: reqHeaders })
  const isMobile = ua.device.type === 'mobile' || ua.device.type === 'tablet'

  const cookieStore = await cookies()
  const initialThemeMode = (cookieStore.get('theme')?.value as 'light' | 'dark') || 'light'

  const preloadedState: RootState = {
    app: {
      isMobile,
    },
  }

  // Select theme based on initial mode
  const selectedTheme = initialThemeMode === 'dark' ? darkTheme : lightTheme

  return Html({
    lang: 'en',
    className: initialThemeMode === 'dark' ? 'dark-theme' : 'light-theme',
    'data-theme': initialThemeMode,
    children: [
      Body({
        className: `${geistSans.variable} ${geistMono.variable} font-sans`,
        children: Node(Wrapper, {
          preloadedState,
          initialTheme: selectedTheme,
          children,
        }),
      }),
    ],
  }).render()
}

Page Components (src/app/page.ts)

Page components now benefit from automatic theme resolution through Context without needing to pass theme props to individual components.

'use client'

import { Center, Column, H1, Button, Text, Portal, A, Row, Div } from '@meonode/ui'
import { PortalWrapper } from '@src/components/Wrapper'
import { useEffect, useState } from 'react'

export default function HomePage() {
  return Center({
    minHeight: '100dvh',
    padding: 'theme​.spacing.xl',
    backgroundColor: 'theme​.base',
    color: 'theme​.base.content',
    children: Column({
      gap: 'theme​.spacing.lg',
      maxWidth: '800px',
      textAlign: 'center',
      children: [
        H1('Welcome to MeoNode UI', {
          fontSize: 'theme​.text.3xl',
          color: 'theme​.primary',
          marginBottom: 'theme​.spacing.md',
        }),
        Text('Build React UIs with type-safe fluency without JSX syntax', {
          fontSize: 'theme​.text.lg',
          lineHeight: 1.6,
          marginBottom: 'theme​.spacing.xl',
        }),

        // Feature highlights
        Row({
          gap: 'theme​.spacing.md',
          justifyContent: 'center',
          flexWrap: 'wrap',
          marginBottom: 'theme​.spacing.xl',
          children: [
            FeatureBadge('🚀 No JSX'),
            FeatureBadge('🎯 Type-Safe'),
            FeatureBadge('🌍 Context Themes'),
            FeatureBadge('⚡ RSC Ready'),
          ],
        }),

        // Action buttons
        Row({
          gap: 'theme​.spacing.lg',
          justifyContent: 'center',
          flexWrap: 'wrap',
          children: [
            A({
              backgroundColor: 'theme​.primary',
              color: 'theme​.primary.content',
              padding: 'theme​.spacing.md theme​.spacing.xl',
              borderRadius: '12px',
              fontSize: 'theme​.text.lg',
              fontWeight: 600,
              textDecoration: 'none',
              border: '2px solid transparent',
              transition: 'all 0.3s ease',
              css: {
                '&:hover': {
                  transform: 'translateY(-2px)',
                  boxShadow: '0 8px 16px rgba(33, 150, 243, 0.3)',
                  borderColor: 'theme​.primary.content',
                },
              },
              href: 'https://ui.meonode.com/docs/getting-started/overview',
              rel: 'noopener noreferrer',
              children: '📚 Explore Docs',
            }),
            Button('🎯 Interactive Demo', {
              backgroundColor: 'theme​.secondary',
              color: 'theme​.secondary.content',
              padding: 'theme​.spacing.md theme​.spacing.xl',
              borderRadius: '12px',
              fontSize: 'theme​.text.lg',
              fontWeight: 600,
              cursor: 'pointer',
              transition: 'all 0.3s ease',
              css: {
                '&:hover': {
                  transform: 'translateY(-2px)',
                  boxShadow: '0 8px 16px rgba(156, 39, 176, 0.2)',
                },
              },
              onClick: () => InteractiveModal(),
            }),
          ],
        }),
      ],
    }),
  }).render()
}

// Feature badge component
const FeatureBadge = (text: string) =>
  Div({
    backgroundColor: 'theme​.surface',
    color: 'theme​.surface.content',
    padding: 'theme​.spacing.sm theme​.spacing.md',
    borderRadius: '20px',
    fontSize: 'theme​.text.sm',
    fontWeight: 500,
    border: '1px solid theme​.primary',
    children: text,
  })

// Enhanced Interactive Modal
const InteractiveModal = Portal<{}>(PortalWrapper, ({ portal }) => {
  const [count, setCount] = useState(0)
  const [selectedColor, setSelectedColor] = useState('primary')

  useEffect(() => {
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape') portal.unmount()
    }
    document.addEventListener('keydown', handleEscape)
    return () => document.removeEventListener('keydown', handleEscape)
  }, [portal])

  return Center({
    position: 'fixed',
    inset: 0,
    backgroundColor: 'rgba(0,0,0,0.6)',
    backdropFilter: 'blur(8px)',
    zIndex: 1000,
    onClick: e => {
      if (e.currentTarget === e.target) portal.unmount()
    },
    children: [
      Column({
        backgroundColor: 'theme​.base',
        color: 'theme​.base.content',
        padding: 'theme​.spacing.xl',
        borderRadius: '20px',
        maxWidth: '500px',
        margin: 'theme​.spacing.lg',
        boxShadow: '0 20px 40px rgba(0,0,0,0.3)',
        gap: 'theme​.spacing.lg',
        css: {
          animation: 'slideIn 0.3s ease-out',
          '@keyframes slideIn': {
            from: { opacity: 0, transform: 'scale(0.9) translateY(-20px)' },
            to: { opacity: 1, transform: 'scale(1) translateY(0)' },
          },
        },
        children: [
          H1('🎯 Interactive Demo', {
            fontSize: 'theme​.text.2xl',
            textAlign: 'center',
            color: `theme​.${selectedColor}`, // Dynamic theme resolution
          }),
          Text('Context-based theming in action!', {
            textAlign: 'center',
            opacity: 0.8,
            fontSize: 'theme​.text.base',
          }),

          // Counter demo
          Column({
            gap: 'theme​.spacing.md',
            padding: 'theme​.spacing.lg',
            backgroundColor: 'theme​.surface',
            borderRadius: '12px',
            children: [
              Text(`Counter: ${count}`, {
                fontSize: 'theme​.text.xl',
                textAlign: 'center',
                fontWeight: 600,
                color: `theme​.${selectedColor}`,
              }),
              Row({
                gap: 'theme​.spacing.md',
                justifyContent: 'center',
                children: [
                  Button('-', {
                    backgroundColor: 'theme​.error',
                    color: 'theme​.error.content',
                    padding: 'theme​.spacing.sm theme​.spacing.md',
                    borderRadius: '8px',
                    cursor: 'pointer',
                    fontWeight: 600,
                    onClick: () => setCount(c => Math.max(0, c - 1)),
                  }),
                  Button('+', {
                    backgroundColor: 'theme​.success',
                    color: 'theme​.success.content',
                    padding: 'theme​.spacing.sm theme​.spacing.md',
                    borderRadius: '8px',
                    cursor: 'pointer',
                    fontWeight: 600,
                    onClick: () => setCount(c => c + 1),
                  }),
                ],
              }),
            ],
          }),

          // Theme color selector
          Column({
            gap: 'theme​.spacing.sm',
            children: [
              Text('Theme Colors:', {
                fontSize: 'theme​.text.sm',
                textAlign: 'center',
                fontWeight: 500,
              }),
              Row({
                gap: 'theme​.spacing.sm',
                justifyContent: 'center',
                children: ['primary', 'secondary', 'accent', 'success'].map(color =>
                  Button(color, {
                    backgroundColor: selectedColor === color ? `theme​.${color}` : 'transparent',
                    color: selectedColor === color ? `theme​.${color}.content` : `theme​.${color}`,
                    border: `2px solid theme​.${color}`,
                    padding: 'theme​.spacing.sm theme​.spacing.md',
                    borderRadius: '6px',
                    cursor: 'pointer',
                    fontSize: 'theme​.text.sm',
                    textTransform: 'capitalize',
                    transition: 'all 0.2s ease',
                    onClick: () => setSelectedColor(color),
                  }),
                ),
              }),
            ],
          }),

          Button('Close Modal (ESC)', {
            backgroundColor: 'theme​.base.content',
            color: 'theme​.base',
            padding: 'theme​.spacing.md theme​.spacing.lg',
            borderRadius: '10px',
            cursor: 'pointer',
            fontWeight: 500,
            css: {
              '&:hover': {
                opacity: 0.8,
              },
            },
            onClick: () => portal.unmount(),
          }),
        ],
      }),
    ],
  })
})

Migration from v0.2.x to v0.3+

Key Breaking Changes

  1. Theme System: Component-level theme prop removed in favor of ThemeProvider
  2. Theme Structure: Themes now require mode and system properties
  3. Context-Based: All theme resolution now happens through React Context

Migration Steps

Before (v0.2.x):

// ❌ No longer works in v0.3+
const MyComponent = () =>
  Div({
    theme: myTheme,
    backgroundColor: 'theme​.primary',
    children: 'Hello World',
  })

After (v0.3+):

// ✅ v0.3+ approach
const App = () =>
  ThemeProvider({
    theme: myTheme, // New theme structure required
    children: [
      Div({
        backgroundColor: 'theme​.primary', // Automatic resolution
        children: 'Hello World',
      }),
    ],
  })

Theme Structure Update

Update your theme objects to follow the new v0.3+ structure:

// Before (v0.2.x)
const oldTheme = {
  primary: '#2196F3',
  secondary: '#9C27B0',
  // ... other properties
}

// After (v0.3+)
const newTheme: Theme = {
  mode: 'light', // Required
  system: {
    // Required wrapper
    primary: {
      default: '#2196F3',
      content: '#FFFFFF',
    },
    secondary: {
      default: '#9C27B0',
      content: '#FFFFFF',
    },
    // ... other properties
  },
}

Boilerplate / Example Repository

Check out the nextjs-meonode repository for a complete, working example of a Next.js project integrated with MeoNode UI. This repository demonstrates best practices, including Context-based theming, Redux Toolkit state management, and React Server Components support.

Repository Details:

  • Next.js latest with the App Router
  • @meonode/ui ^1.1.1 with Context-based theming
  • @meonode/mui ^1.2.0 for Material-UI integration
  • Redux Toolkit for state management
  • TypeScript for a fully type-safe development experience
  • React Server Components support

Best Practices for Next.js Integration

  1. Server Components: MeoNode UI components with RSC support can be rendered on the server, but interactive components using hooks must be declared with 'use client'.

  2. Theme Provider Placement: Always wrap your app with ThemeProvider at the highest level to ensure theme context is available throughout your component tree.

  3. State Management: Use Redux Toolkit or Zustand for complex global state. The theme system now uses React Context, eliminating the need for theme-related state management.

  4. Performance: Utilize Next.js's built-in optimizations and MeoNode UI's automatic CSS optimization through Emotion.

  5. Portal Components: Use the updated PortalWrapper wrapper for modals and overlays to ensure they have access to the theme context.

  6. Theme Switching: Implement theme switching using useState or a theme management library, updating the ThemeProvider's theme prop.

  7. Accessibility: Ensure your components include proper ARIA attributes and follow semantic HTML principles for a better user experience.


Additional Resources

On this page