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.
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
Sloteither 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
- Portal System: Always include
PortalHost()within yourThemeProviderchildren to ensure portals inherit the theme context. - StyleRegistry at root layout: Import from
@meonode/ui/nextjs-registryand wrap your app once insrc/app/layout.ts(insideBody, aroundWrapperand all route segments). Do not mount a second registry in nested layouts or insideWrapper. - 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. - No theme on the server: Render one document for every reader and let
themeScriptapply 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. - 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, acheckedstate, 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 frommode, gate it onhydratedfromuseTheme(). - usePortal Hook: Prefer
usePortalfor all overlays. It manages the portal stack automatically and provides full type safety. - Semantic Tokens: Always use tokens like
theme.primaryinstead of hardcoded colors for automatic light/dark mode support.
Additional Resources
- Live Example: Check out the nextjs-meonode repository to see MeoNode UI in action.
- Core Library: Refer to the official @meonode/ui github for the latest features.
- Release Notes: Check Release Notes for detailed changelog.
Related FAQ
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
- Next.js Integration Guide for MeoNode UI