Packaging render plugins
You can ship a site renderer in three ways. In every case, do not bundle React — the theme injects a single React instance for you. If you run your own bundler, mark React as external.
external: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'];The CLI uses that same external list when it bundles .tsx / .jsx / .ts sources.
Writing UI: createElement vs JSX¶
You have two equivalent ways to build the React tree inside a NodeRenderer:
| Style | What you write | Needs a JSX transform? |
|---|---|---|
createElement | React.createElement (often aliased as h) from the ASTRenderer context | No — plain JavaScript ESM |
| JSX / TSX | <aside>…</aside> in .jsx / .tsx source | Yes — esbuild (yours or the CLI) |
Both styles can close over ASTComponent for nested AST nodes. Choose based on packaging:
| Packaging | createElement | JSX / TSX |
|---|---|---|
Vanilla ESM (single .mjs / .js, no build) | Yes — preferred | No — browsers load the file as-is; JSX would not compile |
Pre-bundled ESM (your esbuild → commit .mjs) | Yes | Yes — your bundler runs the JSX transform |
| Raw TSX (CLI bundles) | Yes (in .ts / .js if you prefer) | Yes — the CLI transforms JSX for you |
Rule of thumb: use createElement for zero-build vanilla modules; use JSX when something is already bundling the file (your script or curvenote start / build).
Vanilla ESM (single file)¶
Hand-write one .mjs module and point renderers[].source at it. No build step. Use createElement — this path has no JSX transform.
// modules/fancy-note.mjs
export default function ASTRenderer({ React, ASTComponent }) {
const { createElement: h } = React;
function FancyNote({ node }) {
return h('aside', null, h(ASTComponent, { ast: node.children }));
}
return { fancyNote: FancyNote };
}Register it from your Curvenote plugin:
{ name: 'FancyNote', source: '../modules/fancy-note.mjs' }Pre-bundled ESM¶
Write TSX/JSX (or plain createElement in TS/JS), run esbuild yourself, and commit the .mjs output. Point source at the built file. Use this when you want a checked-in artifact or a custom build pipeline.
Your bundler must transform JSX if you use it:
// scripts/build-renderers.mjs (sketch)
await esbuild.build({
entryPoints: ['src/sparkle-card.tsx'],
outfile: 'modules/sparkle-card.mjs',
format: 'esm',
bundle: true,
jsx: 'transform',
jsxFactory: 'React.createElement',
external: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
});{ name: 'SparkleCard', source: '../modules/sparkle-card.mjs' }Raw TSX (CLI bundles)¶
Point source at a .tsx (or .jsx / .ts) file. The Curvenote CLI bundles it at site build time and watches it during curvenote start, including the JSX transform. This is the simplest path if you want JSX without maintaining a local build script.
{ name: 'GlowPanel', source: '../src/glow-panel.tsx' }import React, { useState } from 'react';
export default function ASTRenderer({ ASTComponent }) {
function GlowPanel({ node }) {
const [open, setOpen] = useState(true);
return (
<aside>
<button type="button" onClick={() => setOpen((v) => !v)}>
{node.title ?? 'Panel'}
</button>
{open ? <ASTComponent ast={node.children} /> : null}
</aside>
);
}
return { glowPanel: GlowPanel };
}Bare import … from 'react' works because the theme publishes a host import map that resolves those specifiers to the theme’s React. Prefer closing over React / ASTComponent from the factory when you need nested AST nodes without relying on imports — that pattern works with both createElement and JSX.
Bundling a second copy of React will break hooks and context. Always leave React external — whether you bundle locally or let the CLI do it.
With packaging sorted, see how to override built-ins or add new nodes.
- AST
- Abstract Syntax Tree
- CLI
- Command Line Interface