Skip to content

JSX templates

Apostrophe page, widget, and component templates can be written in JSX as an alternative to Nunjucks. For developers already comfortable with React or another JSX-aware framework, this means modern editor support, real JavaScript control flow, and accurate error reporting with source maps, without standing up a separate front-end project.

INFO

This guide assumes you have written JSX before. It focuses on the Apostrophe-specific equivalents of Nunjucks features rather than on JSX itself.

JSX templates are a server-side rendering option. They do not imply React: there is no virtual DOM, no client runtime, and no front-end framework requirement. JSX here is simply an alternate JavaScript syntax that accommodates inline markup, evaluated on the server in the same place Nunjucks would have run.

JSX interoperates with Nunjucks in one direction: a .jsx template can extend or include a .html template (with block overrides where appropriate), but a .html template cannot extend or include a .jsx template. In practice this means you migrate a project from the leaves up, converting individual page and widget templates to JSX while keeping layout.html and the core Nunjucks templates in place. See Migration order for the rules.

Converting an existing project?

Migrating templates with an AI assistant provides instructions you can copy into your project's CLAUDE.md or AGENTS.md, so a coding assistant follows the rules on this page instead of guessing at them.

INFO

Right now, the easiest way to get a peek at a working project with JSX templates is:

bash
git clone https://github.com/apostrophecms/public-demo public-demo-jsx
cd public-demo-jsx
git checkout jsx
npm install
npm run dev

File location and naming

JSX templates live in the same views/ directories as Nunjucks templates, and Apostrophe finds them with the same lookup rules:

modules/default-page/views/page.jsx
modules/feature-card-widget/views/widget.jsx
modules/blog/views/newest.jsx
views/layout.jsx

When both page.jsx and page.html exist for the same module, the .jsx version is used. Rename a single .html file to .jsx (and convert its contents) to migrate it; no other configuration is required.

Anatomy of a JSX template

A JSX template exports a default function that returns markup. The function takes two arguments:

  1. data: the same data object you would have referenced as data.* in Nunjucks. Destructure the props you need, just as you would in a React component.
  2. An object of Apostrophe template helpers: { apos, helpers, Area, Component, Extend, Template, Widget }.
jsx
export default function({ page }, { Area }) {
  return (
    <>
      <h1>{page.title}</h1>
      <Area doc={page} name="main" />
    </>
  );
}
modules/default-page/views/page.jsx

The function may be async. It does not need to be: see Async without async below.

The second argument

NamePurpose
aposThe same object the rest of Apostrophe calls self.apos. Call any module method directly; JSX templates can await.
helpersThe Nunjucks-oriented helper functions (mostly thin wrappers around apos.util and related modules). Use these when you want the exact behavior of an existing Nunjucks helper or filter.
AreaRenders an area. Replaces {% area ... %}.
ComponentInvokes an async component. Replaces {% component ... %}.
TemplateRenders another template by name with include semantics: props are passed as data. Replaces {% include %}. Against a JSX target, also serves as {% extends %} because props are data.
ExtendRenders another template by name with extends semantics. Against a Nunjucks target, props become named {% block %} overrides. Against a JSX target, behaves identically to Template.
WidgetRenders a single widget directly. Only needed if you are reimplementing area.html in JSX.

Nunjucks to JSX cheat sheet

Interpolation

nunjucks
<h1>{{ data.page.title }}</h1>
jsx
<h1>{page.title}</h1>

Note the cognitive shift: in JSX, data is the first function argument. Destructure it once at the top and reference fields directly rather than as data.page.title.

Conditionals

nunjucks
{% if data.user %}
  <a href="/logout">Log out</a>
{% endif %}
jsx
{user && <a href="/logout">Log out</a>}

For if/else, the ternary is the usual idiom:

jsx
{user
  ? <a href="/logout">Log out</a>
  : <a href="/login">Log in</a>}

Loops

nunjucks
{% for product in data.products %}
  <li><a href="{{ product._url }}">{{ product.title }}</a></li>
{% endfor %}
jsx
{products.map(product => (
  <li>
    <a href={product._url}>{product.title}</a>
  </li>
))}

INFO

React-flavored attributes like key and ref are accepted but ignored. They exist in React to help the client-side reconciler match elements across renders; there is no reconciler here, so they have nothing to do. Don't bother adding them.

Areas

nunjucks
{% area data.page, 'main' %}
jsx
<Area doc={page} name="main" />

Context options become an ordinary prop:

jsx
<Area
  doc={page}
  name="main"
  contextOptions={{
    '@apostrophecms/image': {
      sizes: '(min-width: 600px) 45vw, 530px'
    }
  }}
/>

Async components

nunjucks
{% component 'product:newest' with { max: 3 } %}
jsx
<Component module="product" name="newest" max={3} />

The component function defined in modules/product/index.js is invoked exactly as before, and Apostrophe locates its template using the same resolution rules as Nunjucks — see render().

Because JSX templates can run async code on their own (calling any method of any Apostrophe module directly), many components that previously existed only to expose async data to a template are no longer strictly necessary. They remain useful when you want a named, reusable separation of concerns.

Including another template

nunjucks
{% include "footer.html" %}
jsx
<Template name="footer" />

Cross-module references use the same module:file syntax as Nunjucks:

jsx
<Template name="blog:preview" item={item} />

If the template itself expects a prop literally named name, use templateName to disambiguate:

jsx
<Template templateName="blog:preview" name="fancy" item={item} />

name is forwarded to the rendered template as a prop only when templateName is also present. templateName is never forwarded.

What does not carry over from React

JSX here is a syntax for producing HTML on the server. The runtime walks your returned tree once and serializes it to a string — there is no component lifecycle, no reconciler, and no client-side runtime. Most React knowledge transfers, but the following do not, and none of them raise an error. They produce wrong markup silently.

Attribute names are mostly passed through verbatim

Three groups are translated:

WrittenRendered
classNameclass
htmlForfor
SVG camelCase properties — strokeWidth, fillRule, clipPath, xlinkHref, …stroke-width, fill-rule, clip-path, xlink:href, …

Write className, not class. It is one of only three names the runtime translates, so it is supported behaviour rather than a React alias that happens to survive — and it is what the examples throughout this documentation use. Plain class also reaches the HTML intact, but consistency matters more than the two saved characters, and the two forms do not mix:

DANGER

Never put both on the same element. The runtime translates className and passes class through, so <div class="a" className="b"> emits two class attributes and browsers keep only the first.

Everything else reaches the HTML exactly as you typed it. data-* and aria-* attributes pass through unchanged, which is what you want. But React's wider alias table is not implemented, so these do not become their HTML equivalents:

jsx
<img srcSet={srcset} />
jsx
<meta httpEquiv="refresh" />

Write the HTML attribute name instead:

jsx
<img srcset={srcset} />
jsx
<meta http-equiv="refresh" />

srcSet is a special case worth calling out: it appears to work, because HTML parsing is case-insensitive about attribute names. Write srcset anyway — the casing is meaningless here and it misleads the next reader.

React's form conventions are likewise absent. Use the real HTML attributes:

jsx
<input value={piece.title} checked={piece.featured} />

Event handler props do not work

There is no event system and no hydration. A function passed as a prop is stringified into the attribute value:

jsx
<button onClick={handleClick}>Save</button>

Attach behavior from browser-side JavaScript instead, targeting the element by class or data attribute:

jsx
<button className="save-button" data-piece-id={piece._id}>Save</button>

A lowercase inline onclick="…" string still works, because it passes through verbatim like any other attribute. That is ordinary HTML, not React — leave existing onclick attributes lowercase when converting a template rather than "tidying" them into onClick.

style takes a string, not an object

jsx
<div style={{ color: 'red' }} />

renders style="[object Object]". Pass a string instead:

jsx
<div style="color: red" />

false, null, and undefined remove the attribute entirely

Any prop whose value is false, null, or undefined is omitted. A value of true renders the bare attribute. This matches HTML's boolean attributes and is usually what you want:

jsx
<input disabled={!canEdit} />

It differs from React for aria-*, where the string "false" is meaningful and an absent attribute is not the same thing:

jsx
<div aria-hidden={false} />

drops the attribute entirely. Pass the string yourself:

jsx
<div aria-hidden={String(isHidden)} />

Non-string values are coerced, not validated

An object used as a child renders as [object Object] rather than raising an error. Void elements given children serialize as malformed markup — <img>…</img> — instead of being rejected. The runtime will not catch these for you.

Absent entirely

Hooks, state, context, refs, portals, hydration, class components, memo, and forwardRef do not exist. key and ref props are accepted and silently discarded — they exist in React to help the client-side reconciler, and there is no reconciler here. Don't bother adding them.

INFO

The useful mental model is HTML written with JSX syntax, plus function components and Apostrophe's async template helpers — not server-rendered React.

Extending templates

JSX has no concept of named blocks. Markup the parent should render is passed in as props, including the implicit children prop made up of markup between the opening and closing tags, matching React conventions.

Extending another JSX template

Pass named slots as props and the main body as children. The layout receives the slots in its data argument and the body as children, exactly like a React function component.

jsx
export default function({ page, global }, { Area, Component, Template }) {
  return (
    <Template templateName="layout"
      beforeMain={
        <header>
          <h2>Header Override</h2>
        </header>
      }
      afterMain={
        <footer>
          <Area doc={global} name="footer" />
        </footer>
      }
    >
      <h3>The Main Show</h3>
      <Component module="blog" name="recent" />
      <Area doc={page} name="body" />
    </Template>
  );
}
modules/default-page/views/page.jsx

The matching layout destructures the named slots and the implicit children prop:

jsx
export default function(
  { outerLayout, beforeMain, children, afterMain },
  { Template }
) {
  return (
    <Template templateName={outerLayout}
      main={
        <>
          {beforeMain || <header>Default Header</header>}
          {children}
          {afterMain || <footer>Default Footer</footer>}
        </>
      }
    />
  );
}
views/layout.jsx

Extending a Nunjucks template (named block overrides)

Use <Extend> to extend a Nunjucks template with JSX-supplied block overrides. Each prop name maps to a {% block <name> %} in the target; the JSX value replaces the block contents. This is the migration path: keep layout.html exactly as it is and rewrite individual page templates in JSX one at a time.

jsx
export default function(
  { page, global },
  { Area, Component, Extend }
) {
  return (
    <Extend templateName="layout"
      beforeMain={
        <header>
          <h2>Header Override</h2>
        </header>
      }
      main={
        <>
          <h3>The Main Show</h3>
          <Component module="blog" name="recent" />
          <Area doc={page} name="body" />
        </>
      }
      afterMain={
        <footer>
          <Area doc={global} name="footer" />
        </footer>
      }
    />
  );
}
modules/default-page/views/page.jsx

INFO

<Template> and <Extend> differ only when the target resolves to a Nunjucks file:

  • <Template templateName="layout" foo={…} /> is include semantics: foo arrives in data.foo and {% block %} declarations in the target are not overridden.
  • <Extend templateName="layout" foo={…} /> is extends semantics: foo overrides {% block foo %} in the target.

When the target is a .jsx file, both behave identically (props are the data argument, markup between tags is children). Use whichever name reads better in context.

WARNING

Write templateName without a file extension. Apostrophe strips a known extension and then searches .jsx, .njk, .html in that order, so templateName="layout.html" does not pin the target to the Nunjucks file — it still resolves layout.jsx first if one exists. The extension reads as a guarantee it does not provide.

Coming from blocks and super()

Nunjucks lets a child override a block and call {{ super() }} inside it to render the parent's original content, then add to it:

nunjucks
{% block beforeMain %}
  {{ super() }}
  {% render header.headerArea(data.page) %}
{% endblock %}

There is no JSX equivalent, and none is planned. Blocks are inheritance: a block is an overridable method, so super() is just a method call. Props are composition: a prop is a value the child computes and hands to the parent, and a value has no superclass. Every component-oriented framework works this way — React, Vue, Svelte and Web Components all treat default slot content as fallback only, with no way to invoke it from an override.

So the migration is not to replace super(). It is to make the block additive, so nothing needs to call it.

The target shape: the layout owns what is shared

Give the parent the invariant part and let it expose a slot for what varies:

jsx
export default function({ outerLayout, beforeMain, children }, { Template }) {
  return (
    <Template templateName={outerLayout}
      beforeMain={
        <>
          <Navigation />
          {beforeMain}
        </>
      }
      main={<main className="mb-4">{children}</main>}
    />
  );
}
views/layout.jsx

A page now passes only its own header, and never learns what the navigation is — which was the point of the original super() call:

jsx
<Extend templateName="layout" beforeMain={<Header page={page} />} />

The transitional shape: the layout is still Nunjucks

While the layout remains .html, get the same effect by nesting a finer block inside the coarse one. The outer block keeps the shared markup; children override only the inner one:

nunjucks
{% block beforeMain %}
  {% render navigation.navigationArea() %}
  {% block pageHeader %}{% endblock %}
{% endblock %}
views/layout.html

Note this is nesting, not moving the markup out of the block — a template that uses {% extends %} can only contribute through blocks, so there is no "outside" available to it.

WARNING

Subdividing a block changes the parent's contract. Any Nunjucks child still overriding the outer block will drop or duplicate the shared markup, so convert those children first.

When you cannot edit the parent

Hoist the parent's default into a component named after the block, not after its current contents, and let the parent's block body contain nothing else:

jsx
// Right: names the block's default, so later changes to it still reach the child
<Extend
  templateName="layout"
  beforeMain={<><BeforeMainDefault /><Header page={page} /></>}
/>;

// Wrong: names today's contents, and silently stops tracking the layout tomorrow
<Extend
  templateName="layout"
  beforeMain={<><SiteNav /><Header page={page} /></>}
/>;

What to watch for

  • Scope does not travel. super() runs in the parent's context and can see variables set above it in the layout. A component evaluates in the child's scope, so pass those in as props. This is the most common cause of a conversion that looks right and renders wrong.
  • Omitting the default fails silently. Pass beforeMain={<Header />} alone and the navigation simply disappears — no error, and the page still renders.
  • A function prop will not work. beforeMain={(Default) => <><Default /><Header /></>} looks like a React render prop, but props here are values the parent renders, not callbacks it invokes.
  • An intermediate template does not help. Inserting a base-with-nav template between the layout and the pages recreates the identical constraint one level down.

Composition also buys you things super() could not: place your content before the shared markup, include it conditionally, or pass it props.

Migration order

JSX and Nunjucks coexist freely in the same project, but there is one hard rule:

A .html template cannot {% extends %}, {% include %}, or {% import %} a .jsx template. Nunjucks's template loader has no way to invoke the JSX renderer. The reverse (JSX consuming Nunjucks) is fully supported, including block overrides via <Extend>.

That asymmetry determines how to migrate a project. Two orderings work; the hybrid case does not.

Bottom-up (recommended): convert leaves first. Rename page.html files to page.jsx one at a time. Each new page.jsx extends the existing layout.html with <Extend templateName="layout" … />. Other page.html files continue to work unchanged because they still extend a .html layout.

Top-down in one cut: convert a whole inheritance chain together. Once every page.html that extends layout.html is gone (either deleted or converted to .jsx), you can rename layout.html to layout.jsx. The new layout.jsx extends the core outer layout with <Extend templateName={data.outerLayout} … />.

Hybrid: don't. Don't leave any .html template extending a .jsx template. That combination cannot work.

TIP

If you are using a coding assistant for the conversion, Migrating templates with an AI assistant provides instructions you can copy into your project's CLAUDE.md or AGENTS.md, covering the ordering rules above along with the JSX behavior that most often trips up assistants.

INFO

Core's outerLayoutBase.html will remain Nunjucks for the foreseeable future, because every existing project's layout.html extends it via {% extends data.outerLayout %}. A fully-JSX project typically ends up with a .jsx layout that extends the core Nunjucks outer layout through <Extend>. This is the intended steady state, not a limitation.

Auto-escaping and raw HTML

JSX auto-escapes interpolated values, both inside element bodies and inside attribute values. This matches both React's and Nunjucks's defaults.

When you need to emit trusted raw HTML (for example, when overriding the rich text widget's template), use React's dangerouslySetInnerHTML:

jsx
<div dangerouslySetInnerHTML={{ __html: widget.content }} />

The attribute name is intentionally alarming. Treat it that way: never pass untrusted input through it.

Async without async

A JSX template can render an <Area>, a <Component>, or a <Template> whose default export is async without itself being declared async. Apostrophe collects pending output as it renders, awaits everything, and assembles the final HTML before the response is sent. You can mix synchronous and asynchronous markup freely.

Declare the template function async only when it needs to fetch data before rendering, for example by calling an external API. The async component pattern is often a cleaner place for that fetch, since it separates data-loading from markup.

INFO

This rendering model is not streaming. There is no React Suspense equivalent: the whole page is rendered, all pending pieces are awaited, and the response is sent in one piece. For many applications, this is enough, especially when combined with HTMX, web components and our standard front end pipeline. If you need more, we recommend an Apostrophe Astro hybrid project.

import, require, and inline components

JSX templates are real JavaScript modules. Use either import or require to pull in helpers, and write additional pure-function components inline in the same file:

jsx
import { formatPrice } from '../lib/format.js';

function Price({ amount }) {
  return <span className="price">{formatPrice(amount)}</span>;
}

export default function({ product }) {
  return (
    <article>
      <h2>{product.title}</h2>
      <Price amount={product.price} />
    </article>
  );
}

This is in addition to <Template name="...">, which exists for parity with Nunjucks's string-based lookup and to support Apostrophe's module:file cross-module syntax. Use direct import when the partial is co-located and you do not need name-based resolution; use <Template> when you do.

Coming from macros and fragments

Nunjucks offers two ways to package reusable markup, and both become function components here.

A macro is a reusable block of markup that cannot run asynchronous code. A fragment is Apostrophe's answer to that limitation — the same idea, but able to contain {% area %} and async components. JSX templates are real JavaScript, so a function component can already do async work. The distinction the two features existed to draw does not apply, and there is no separate construct to learn.

NunjucksJSX
{% macro x() %} or {% fragment x() %}function X() { … }
{% render x() %}<X />
ArgumentsProps
{% import 'fragments/file.html' as f %}import X from './file.jsx'
{% import 'module-name:file.html' %}<Template name="module-name:file" />
rendercaller() / {% rendercall %}The implicit children prop

Markup passed between a component's tags arrives as children, which is the direct equivalent of rendercaller():

jsx
function Highlighter({ children }) {
  return <aside className="highlight">{children}</aside>;
}

export default function({ page }) {
  return (
    <Highlighter>
      Fun fact: {page.funFact}
    </Highlighter>
  );
}

WARNING

A macro or fragment and every template that imports it must convert together. The moment file.html becomes file.jsx, any remaining .html template importing it breaks — Nunjucks cannot load a JSX template. Count the importers before you start. See Migration order.

Widget templates

Widget templates work the same way as page templates: a default-exported function receiving data and the helper object. The widget data is on widget, and options and context options arrive as you would expect:

jsx
export default function({ widget, contextOptions }, { apos, Area }) {
  const attachment = apos.image.first(widget._image);
  return (
    <section className="feature-card">
      {attachment && (
        <img
          className="feature-card__image"
          src={apos.attachment.url(attachment, { size: 'full' })}
          alt={widget._image[0].alt || ''}
        />
      )}
      <h2 className="feature-card__title">{widget.title}</h2>
      <div className="feature-card__body">
        <Area doc={widget} name="body" />
      </div>
      {widget.link && (
        <a className="feature-card__link" href={widget.link}>
          {widget.linkLabel || 'Learn more'}
        </a>
      )}
    </section>
  );
}
modules/feature-card-widget/views/widget.jsx

See Template data for the full list of properties available in widget templates.

Error reporting

JSX templates are compiled with source maps. Runtime errors point at the original .jsx line and column rather than at the compiled output. Syntax errors and undefined variables surface as ordinary JavaScript errors with accurate locations, a significant improvement over Nunjucks's reporting.

When to keep using Nunjucks

JSX is an alternative, not a replacement. The Nunjucks pipeline remains a first-class, fully supported option, and is the right choice when:

  • An existing codebase already uses Nunjucks and its templates do not need to change.
  • Your team prefers tag-based syntax or has Jinja/Twig/Nunjucks experience.
  • You are sharing templates with a tool or workflow that expects Nunjucks.

The two can coexist indefinitely in the same project. Pick the option that fits the team and the file.