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:

StyleWhat you writeNeeds a JSX transform?
createElementReact.createElement (often aliased as h) from the ASTRenderer contextNo — plain JavaScript ESM
JSX / TSX<aside>…</aside> in .jsx / .tsx sourceYes — esbuild (yours or the CLI)

Both styles can close over ASTComponent for nested AST nodes. Choose based on packaging:

PackagingcreateElementJSX / TSX
Vanilla ESM (single .mjs / .js, no build)Yes — preferredNo — browsers load the file as-is; JSX would not compile
Pre-bundled ESM (your esbuild → commit .mjs)YesYes — 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.

Important

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.

Abbreviations
AST
Abstract Syntax Tree
CLI
Command Line Interface