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

Next.js Integration Guide for MeoNode UI

Overview

Next.js is the setup with the most moving parts, because the App Router splits rendering across a server and client boundary and MeoNode's styles have to cross it intact. This guide covers what that requires: StyleRegistry in the root layout so client components' styles reach the document, what styling looks like in a server component, when a nested layout needs a client boundary, and where the theme provider and portal host belong relative to both.

New to the library itself? Start with the overview — this page assumes you know what a node is and covers only what Next.js adds.


Next.js

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 MeoNode UI and its peer dependencies
yarn add @meonode/ui react react-dom @emotion/cache @emotion/react

# This guide also uses Redux Toolkit for app state and @meonode/mui for
# CssBaseline. Both are optional — skip them and the sections that use them.
yarn add @reduxjs/toolkit react-redux @meonode/mui

Next.js Configuration

Configure Emotion's CSS-in-JS functionality and optimize imports.

import type { NextConfig } from 'next'

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

export default nextConfig

Theme Configuration (src/constants/themes/)

Define your design tokens once. Colours are declared as var() references rather than values, so the same token object serves every mode and the palettes live in CSS — which is what lets the server send one document to every reader instead of one per theme.

const themeSystem = {
  text: {
    xs: '0.75rem',
    sm: '0.875rem',
    md: '1rem',
    lg: '1.125rem',
    xl: '1.25rem',
    '2xl': '1.5rem',
    '3xl': '1.875rem',
  },
  spacing: {
    xs: '0.25rem',
    sm: '0.5rem',
    md: '1rem',
    lg: '1.5rem',
    xl: '2rem',
    '2xl': '3rem',
  },
  radius: {
    sm: '2px',
    md: '4px',
    lg: '8px',
    xl: '16px',
    full: '9999px',
  },
}
export default themeSystem
import type { ThemeScriptConfig } from '@meonode/ui'

/**
 * One description of this site's themes, read by both halves — the pre-paint
 * script and the provider — so they cannot disagree about which modes exist.
 */
export const themeConfig = {
  modes: ['light', 'dark'],
  // Where everything falls back to when there is no other answer: a stored mode
  // this site no longer has, a `matchMedia` that throws, storage that is
  // blocked. It has to name a mode your stylesheets define.
  defaultMode: 'light',
  // Where a reader who has chosen nothing starts. Omit it and that is
  // `defaultMode`; `'system'` means follow the operating system.
  defaultPreference: 'system',
  // What the operating system's two words mean here. Rename your modes and this
  // is where you say which is which.
  system: { light: 'light', dark: 'dark' },
} satisfies ThemeScriptConfig

export default themeConfig
import themeSystem from './themeSystem'

/**
 * Colours as references, not values. Both modes share this one object, so the
 * `:root{--meonode-theme-*}` block the provider renders is identical for every
 * reader — the document does not depend on who asked for it.
 */
export const themeTokens = {
  ...themeSystem,
  primary: { default: 'var(--primary-default)', content: 'var(--primary-content)' },
  secondary: { default: 'var(--secondary-default)', content: 'var(--secondary-content)' },
  base: { default: 'var(--base-default)', content: 'var(--base-content)' },
}

export default themeTokens

The values themselves live in CSS, keyed by the attribute the script writes. Put a usable palette on :root rather than only under [data-theme] — every failure path in the script ends with no attribute written, and a reader whose storage is blocked should get a styled page rather than an unstyled one.

:root {
  --primary-default: #2196f3;
  --primary-content: #ffffff;
  --secondary-default: #9c27b0;
  --secondary-content: #ffffff;
  --base-default: #ffffff;
  --base-content: #1a1a1a;
}

[data-theme='dark'] {
  --primary-default: #90caf9;
  --primary-content: #0a0a0f;
  --secondary-default: #ce93d8;
  --secondary-content: #0a0a0f;
  --base-default: #0a0a0f;
  --base-content: #f0f0f5;
}

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.

import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import appSlice from '@src/redux/slice/app.slice'
import { createNode } from '@meonode/ui'
import { Provider } from 'react-redux'

export const initializeStore = (preloadedState?: object) => {
  const store = configureStore({
    reducer: {
      app: appSlice.reducer,
    },
    middleware: getDefaultMiddleware =>
      getDefaultMiddleware()
        .concat
        // your middleware
        (),
    preloadedState,
  })

  setupListeners(store.dispatch)
  return store
}

export type RootState = ReturnType<ReturnType<typeof initializeStore>['getState']>
export type AppDispatch = ReturnType<typeof initializeStore>['dispatch']
export const ReduxProvider = createNode(Provider)

Wrapper Components (src/components/Wrapper.ts)

The Wrapper component is the heart of your client-side providers, integrating Redux, the Theme system, and the Portal System. Keep StyleRegistry out of Wrapper — mount it once in the root layout instead (see below).

'use client'
import { type Children, Node, PortalHost, PortalProvider, ThemeProvider } from '@meonode/ui'
import { StrictMode, useMemo } from 'react'
import { CssBaseline } from '@meonode/mui'
import { themeConfig } from '@src/constants/themes/config'
import { themeTokens } from '@src/constants/themes/tokens'
import { initializeStore, ReduxProvider, RootState } from '@src/redux/store'

export const Wrapper = ({
  preloadedState,
  children,
}: {
  preloadedState?: Partial<RootState>
  children?: Children
  isPortal?: boolean
}) => {
  const store = useMemo(() => initializeStore(preloadedState), [preloadedState])

  return Node(StrictMode, {
    children: PortalProvider({
      children: [
        CssBaseline(),
        ReduxProvider({
          store,
          // The same `themeConfig` the head script was given. Spreading one
          // literal into both is the point: a mode added in one place reaches
          // the script and the provider together.
          children: ThemeProvider({
            ...themeConfig,
            tokens: themeTokens,
            children: Array.isArray(children) ? children.concat(PortalHost()) : [children, PortalHost()],
          }),
        }),
      ],
    }),
  }).render()
}

There is no themeMode prop. It used to arrive from the server, which read it from a cookie — so the document depended on who asked for it and two readers could never share a cached copy.

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

This is the main layout file for Next.js. It uses userAgent to detect devices for preloaded state, places the pre-paint theme script first in <head>, and wraps the entire app in StyleRegistry at the root layout so every client component shares one Emotion cache during SSR.

themeScript reads the reader's stored preference and stamps the resolved mode on <html> in the only window where the answer is still invisible — after the document element exists, before the first paint. There is no flash because there was never a wrong first paint to correct, and no cookie, so the response is the same bytes for everyone and a CDN can cache it.

Place it first in <head>, ahead of any stylesheet. A script that follows a <link rel="stylesheet"> cannot run until that sheet has loaded, which is the delay it exists to avoid. next/script is no use here — even beforeInteractive is too late for this.

import type { Metadata } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import './globals.css'
import { Body, Head, Html, Node, themeScript } from '@meonode/ui'
import { StyleRegistry } from '@meonode/ui/nextjs-registry'
import { headers } from 'next/headers'
import { themeConfig } from '@src/constants/themes/config'
import { ReactNode } from 'react'
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: 'Your App',
  description: 'What your app does, in a sentence.',
}

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 preloadedState: RootState = {
    app: {
      isMobile,
    },
  }

  return Html({
    lang: 'en',
    // `data-theme` is deliberately absent. The server does not know what the
    // reader chose, and guessing is the flash this arrangement removes. The
    // script writes it before the first paint; `suppressHydrationWarning` is
    // what stops React reporting the attribute it did not send.
    suppressHydrationWarning: true,
    children: [
      Head({
        key: 'theme',
        children: themeScript(themeConfig),
      }),
      Body({
        key: 'body',
        className: `${geistSans.variable} ${geistMono.variable} font-sans`,
        children: StyleRegistry({
          children: Node(Wrapper, {
            preloadedState,
            children,
          }),
        }),
      }),
    ],
  }).render()
}

Nested route layouts (docs, dashboards, etc.)

Keep metadata and data fetching in server layout.ts files. A server layout can render styled MeoNode structure directly — page frames with Root / Column / Row, theme tokens, :hover, media queries — see Styling in server components:

import { Main, Nav, Root } from '@meonode/ui'
import type { ReactNode } from 'react'

export default function DocsLayout({ children }: { children: ReactNode }) {
  return Root({
    backgroundColor: 'theme.base.default',
    color: 'theme.base.content',
    children: [
      Nav({
        padding: 'theme.spacing.md',
        css: { '&:hover': { color: 'theme.primary.default' }, '@media (min-width: 768px)': { padding: 'theme.spacing.lg' } },
        children: 'Docs',
      }),
      Main({ children }),
    ],
  }).render()
}

Move chrome into a 'use client' shell component when it needs what only the client has: state, hooks such as useTheme or usePortal, event handlers, or theme functions (theme => …). Render the shell from the server layout with Node(MyShell, { children }).render():

'use client'
import { Column, Main, Node, Root, Row } from '@meonode/ui'
import type { ReactNode } from 'react'

export default function DocsSectionShell({ children }: { children?: ReactNode }) {
  return Root({
    backgroundColor: 'theme.base',
    color: 'theme.base.content',
    children: [
      /* Navbar, Sidebar, etc. */
      Main({ children }),
    ],
  }).render()
}
import { Node } from '@meonode/ui'
import DocsSectionShell from '@src/components/DocsSectionShell'

export default function DocsLayout({ children }: { children: React.ReactNode }) {
  return Node(DocsSectionShell, { children }).render()
}

Styling in server components

A host tag with styles in a server component — Div, Nav, Button, … — renders as the element itself, with a class compiled on the server. Its rules travel with the render as a <style> that React hoists into <head>, so a page that is prerendered, streamed or cached carries them, and the css object is not sent to the browser a second time. Theme tokens become CSS variables, and :hover, media queries and other selectors behave as they do on the client.

A server component cannot read the ThemeProvider above it, so a theme function (theme => …) there has no theme to run with. It is dropped, and in development a [MeoNode] warning names the property. Write the value as a token string instead, or move the node into a client component.

Since 3.1.0 a theme function in a server component is dropped. On 3.0.x it failed the render with "Functions cannot be passed directly to Client Components".

Passing a className into a styled component

A component that styles its own element and accepts a className gets the caller's styles composed into its own, as one class, with the caller's winning a conflict — what Emotion does on the client:

import { createNode, Div } from '@meonode/ui'

function Card({ className, children }: { className?: string; children?: string }) {
  return Div({ className, padding: 16, color: 'theme.base.content', children }).render()
}

const CardNode = createNode(Card)

CardNode({ css: { color: 'theme.primary.default' }, children: 'Hello' }) // renders in theme.primary.default

This holds when the component renders inside a 'use cache' scope and the caller outside it. Classes MeoNode did not generate, such as utility classes, are kept as they are.

Components that clone their child

.render() returns the element itself, with its <style> placed among the children of the nearest HTML element above it, so a parent that clones its child — Radix Slot, an asChild prop, Children.only — receives the element it expects. When nothing in the render can hold the styles, because its root is a component with no HTML element above the styled node (next/link is the usual one), a void element such as Img, or an svg, .render() returns a Fragment of the element and its styles instead, and the props the parent adds land on the Fragment rather than the element. Pass the node, not its rendered element:

import NextLink from 'next/link'
import { Slot } from '@radix-ui/react-slot'
import { createNode, Node } from '@meonode/ui'

const Link = createNode(NextLink)

// The <a> gets `nav-item`.
Node(Slot, { className: 'nav-item', children: Link({ href: '/docs', color: 'theme.primary.default', children: 'Docs' }) })

// It does not: Slot receives a Fragment.
Node(Slot, { className: 'nav-item', children: Link({ href: '/docs', color: 'theme.primary.default', children: 'Docs' }).render() })

Since 3.1.0 this applies to server components. On 3.0.x the rendered element reached Slot either way.

Page Components & Portals (src/app/page.ts)

Use the usePortal hook for managing overlays. Modals are standard components that receive close and data props.

'use client'
import { Center, Column, H1, Button, Text, usePortal } from '@meonode/ui'

export default function HomePage() {
  const portal = usePortal()

  return Center({
    children: Column({
      children: [
        H1('Welcome to MeoNode UI'),
        Button('Open Modal', {
          onClick: () => portal.open(MyModal, { name: 'Developer' })
        })
      ]
    })
  }).render()
}

// Modal Component
const MyModal = ({ data, close }: { data: { name: string }, close: () => void }) => 
  Center({
    position: 'fixed',
    inset: 0,
    backgroundColor: 'rgba(0,0,0,0.5)',
    backdropFilter: 'blur(4px)',
    children: Column({
      backgroundColor: 'theme.base',
      padding: 'theme.spacing.xl',
      borderRadius: 'theme.radius.lg',
      children: [
        Text(`Hello ${data.name}!`),
        Button('Close', { onClick: close })
      ]
    })
  }).render()

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
// next.config.ts — pass the package NAME, not a resolved path.
// Turbopack resolves the plugin itself and fails on an absolute path.
export default { experimental: { swcPlugins: [['@meonode/compiler', {}]] } }

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. An older ~30% off server renders figure has not been re-measured since 2.0.0-beta and is quoted against the runtime of that time.

Both are smaller than figures you may have seen against @meonode/ui@1.x — ~4x and ~1.6x — and that is not a regression in the plugin. Most of what those numbers measured was stable-key hashing, which 2.0.0 removed from the runtime entirely, so there is far less left for compiling to hoist.

See the compiler guide for what compiles, what is left untouched, and how to verify the plugin actually ran.

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.


Best Practices

  1. Portal System: Always include PortalHost() within your ThemeProvider children to ensure portals inherit the theme context.
  2. StyleRegistry at root layout: Import from @meonode/ui/nextjs-registry and wrap your app once in src/app/layout.ts (inside Body, around Wrapper and all route segments). Do not mount a second registry in nested layouts or inside Wrapper.
  3. Nested layouts: Keep the layout file a server component for metadata. It can render styled MeoNode structure with token strings directly; move chrome into a 'use client' shell when it needs state, hooks, event handlers or theme functions.
  4. No theme on the server: Render one document for every reader and let themeScript apply the mode before the first paint. Reading a cookie makes the response depend on who asked for it, so it can never be shared by a cache — and it is not needed, because the script runs before anything is painted.
  5. Never render markup from mode: The server does not know the reader's mode, so a component that branches on it — a sun/moon icon, a checked state, a dark-only background — says one thing on the server and another on the client for anyone not on the default. React answers that mismatch by discarding the server tree and rendering the whole page again, which also clears the attributes the script wrote. Drive those differences from CSS keyed off [data-theme='…'], which is also the only way they are right before React loads. If you genuinely must render from mode, gate it on hydrated from useTheme().
  6. usePortal Hook: Prefer usePortal for all overlays. It manages the portal stack automatically and provides full type safety.
  7. Semantic Tokens: Always use tokens like theme.primary instead of hardcoded colors for automatic light/dark mode support.

Additional Resources


Why am I getting TypeScript build errors with Page components in Next.js?

Most issues come from page export shape mismatches or components returning non-React elements where Next.js expects valid page output. Keep page modules simple and ensure wrapped components return valid rendered output.

How do I use existing JSX components or JSX libraries with MeoNode UI?

Use Node() to wrap third-party JSX components, then pass their props as normal. This keeps interoperability smooth without rewriting external libraries.

More details: /docs/getting-started/faq

On this page