Compiler
@meonode/compiler is an optional build-time plugin that moves work @meonode/ui would otherwise do on every render into your build step.
It is purely an optimization. Nothing about it is required for correctness — every call site it can't prove is safe is left completely untouched, and your app behaves identically whether the plugin is installed, misconfigured, or absent entirely.
Why it exists
Every factory call — Div({...}), P('text', {...}), Node('div', {...}) — normally does three things at runtime, on every single render:
- Classifies each prop as a CSS property vs. a DOM attribute (a lookup against 689 known CSS property names).
- Hashes the prop signature to produce the stable key that drives element caching.
- Resolves
theme.*tokens tovar(--meonode-theme-*)references.
None of that depends on runtime values. It depends only on which prop names appear at the call site and whether the object literal was written with a static shape — all of which is knowable from the source text. So the plugin computes the answers once, at build time, and writes them directly into the call site.
What compiled output looks like
// You write: Div({ padding: 'theme.spacing.md', width, onClick: handler, css: { color: 'red' }, children: [A, B], }) // The plugin emits: Div({ __meo$: 2, __meo$c: { padding: 'var(--meonode-theme-spacing-md)', width }, __meo$d: { onClick: handler }, __meo$k: 'm1a2b3c', __meo$dyn: ['width', 'onClick'], css: { color: 'red' }, children: [A, B], })
Reading that output:
| Key | Meaning |
|---|---|
__meo$ | Marker schema version. Tells the runtime this call site is pre-partitioned. |
__meo$c | Props already known to be CSS properties. |
__meo$d | Props already known to be DOM attributes / handlers. |
__meo$k | The call site's stable key, derived from its source position. |
__meo$dyn | Names of props whose values aren't static, so the runtime knows which ones still need hashing. |
The runtime sees __meo$ and reads the buckets directly instead of re-deriving them, skipping the whole classification and hashing pass.
Note padding: the theme.spacing.md token has already become a var() reference. @meonode/ui does this same conversion at runtime, but its memoization only helps objects declared outside a render body — an inline call site allocates a fresh props object every render, so the cache never hits and the walk repeats. Doing it at build time changes when the conversion happens, never what it produces.
What it measurably buys
Two numbers, because they answer different questions:
| Benchmark | Result |
|---|---|
| Node construction in isolation | 1.80x faster |
| End-to-end SSR render, production build | ~30% faster |
The 1.80x figure covers only prop classification and stable-key hashing. It excludes React, Emotion, and theme resolution, so it is not how much faster a page renders. The end-to-end figure is, measured on a 156-node tree under renderToPipeableStream.
That end-to-end number depends on your @meonode/ui version, because two releases made the runtime cheaper specifically for the shape compiled output has. Each one widened the gap rather than closing it:
| Against | End-to-end SSR |
|---|---|
@meonode/ui 1.7.0 | 15.2% |
@meonode/ui 1.7.1 | 25.5% |
@meonode/ui 1.7.2 | 30.2% |
- 1.7.1 made theme-token conversion allocation-free when there is nothing to convert. Compiled props are already token-free, since the tokens were rewritten at build time.
- 1.7.2 stopped the prop processor allocating per node on the compiled path — no copy of every prop, and no classification work when every key is already partitioned.
So the newer your runtime, the more compiling is worth. Uncompiled call sites keep paying both costs.
Your mileage depends heavily on how token-dense your styles are. This site averages about 0.8 theme.* strings per compiled call site; a codebase using far more or far fewer will see a different split.
Setup
Install it as a dev dependency — it only runs at build time:
npm install --save-dev @meonode/compiler
It requires @meonode/ui 1.7.0 or later, which is the first version whose runtime understands the current marker contract. On older versions the markers are simply ignored and you get the normal runtime path.
1.7.2 or later is recommended. 1.7.1 and 1.7.2 each removed per-node allocation work that only compiled output can skip, taking the end-to-end gain from 15% to roughly 30% — so a newer runtime makes compiling worth more, not less.
Next.js
// next.config.ts import type { NextConfig } from 'next' const nextConfig: NextConfig = { experimental: { swcPlugins: [['@meonode/compiler', {}]], }, } export default nextConfig
Pass the package name, not a resolved path. Turbopack resolves the plugin itself, and handing it an absolute path causes it to fail.
Vite
// vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react-swc' export default defineConfig({ plugins: [ react({ plugins: [['@meonode/compiler', {}]], }), ], })
Wrapped factories
If you re-export or wrap factories in your own package, the plugin can't see through that — it only traces imports from @meonode/ui. Name those packages explicitly:
swcPlugins: [['@meonode/compiler', { factoryModules: ['@meonode/mui'] }]]
This site uses exactly that, so its @meonode/mui call sites compile too.
What compiles, and what doesn't
The plugin physically reorders props into buckets, so it only compiles a call site when that reorder provably can't be observed.
Compiles:
- Plain object literals whose values are literals, identifiers, arrow functions, or nested literal-only objects — the common case.
- Any call site with at most one effectful prop value (a call, member access,
await, assignment). This covers the dominant real-world shapes, likekey: item.idinside a.map()or a single computedbackgroundColor. - Two or more effectful values whose relative order survives bucketing.
- Leading spreads (
{ ...props, padding: '8px' }), with a caveat below.
Bails, and is left exactly as written:
- The callee isn't really a
@meonode/uifactory — shadowed locals, namespace imports (import * as M). - The props argument isn't a plain object literal.
- A spread appears after a static prop (
{ padding: 1, ...rest }), since the spread would need to win and the merge order can't express that. - Computed keys (
{ [k]: v }), numeric keys, getters/setters, shorthand methods. - Two or more effectful values that bucketing would reorder.
Special keys — css, props, ref, key, children, as, theme, disableEmotion — are never bucketed. They stay at the top level in their original relative order. key and children in particular keep their exact runtime semantics: compiling never changes how either is evaluated.
Spread-bearing call sites
A spread's contents aren't known until runtime, so a call site containing one gets prop partitioning but no stable key. The marker and buckets are still emitted, so the classification speedup is retained, but __meo$k and __meo$dyn are omitted entirely and the runtime falls back to its normal signature path.
The reason is subtle and worth knowing: the stable key is a function of source position, so it would be identical across evaluations no matter what the spread contained that time. Emitting it would let two evaluations with different props share a cache entry.
Theme tokens
Only prop values are rewritten, never keys:
Div({ // Rewritten -- direct, bucketed, static string values padding: 'theme.spacing.md', border: '1px solid theme.base.deep', // NOT rewritten -- `css` is a special key, left to the runtime css: { // NOT rewritten -- this is a KEY. `var()` is invalid inside a media // feature, so it must resolve to a concrete value at runtime. '@media (max-width: theme.breakpoint.md)': { padding: 'theme.spacing.sm' }, }, })
Media queries and selectors keep their raw tokens and resolve at runtime, where the live theme is available. Template literals (`theme.spacing.${size}`) aren't static, so they're left alone too. Tokens inside css blocks still work exactly as before — they're just resolved at render time rather than build time.
Verifying it ran
Because bailing is silent and safe, a misconfigured plugin looks exactly like a working one. To confirm it's actually running, grep your build output for the marker:
grep -ro '__meo\$:' .next/server | wc -l
Zero means the plugin isn't loading. Check that you passed the package name rather than a path, and that your host can load SWC WASM plugins at all.
If it doesn't load
The plugin/host boundary is a versioned wire protocol, not a linked ABI, and hosts occasionally bump the plugin ABI generation they accept. A plugin built against one swc_core version can be rejected outright by a host expecting another, usually with an opaque error.
If a framework upgrade suddenly breaks your build, this is the first thing to check — consult the compatibility table in the @meonode/compiler README against your exact Next.js or @swc/core version.
Remember the fallback is total: if the plugin fails to load, @meonode/ui runs correctly without it. You lose the speedup, not the app.
On this page
- Compiler