Skip to content
Docxcelerate

Essentials

Templates

Compose a document tree with TSX, and compute structure with the same JavaScript you write everywhere else.

A template is the tree that describes your document, given a name so a project can point at it.

Nothing is rendered when you write it. Evaluating the JSX only builds elements — which is what lets a component decide what it is later, once it actually has data.

The shape

import { Document, Section, template } from "docxcelerate/template";
import { Greeting, NextSteps } from "./nodes/index.ts";
import type { DocumentData } from "./types.ts";

export const documentTemplate = template<DocumentData>(
  <Document id="tenancy-renewal" title="Tenancy Renewal">
    <Section id="opening" title="Opening">
      <Greeting />
    </Section>
    <Section id="closing" title="Closing">
      <NextSteps />
    </Section>
  </Document>,
);

What wires that JSX to Docxcelerate rather than React is one setting in tsconfig.json:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "docxcelerate/template"
  }
}

Scaffolded workspaces already have this, so you don’t need a pragma at the top of any file. If you’re adding Docxcelerate to a project that points jsxImportSource somewhere else, put /** @jsxImportSource docxcelerate/template */ on line one of the file to override it just there.

Computed structure

A template is written at module scope, where no data exists yet. So if your structure depends on data, it belongs in a component:

export const Enclosures: Section = () => {
  const [state] = useState((data: DocumentData) => ({ items: data.enclosures }));

  return (
    <Section id="enclosures" title="Enclosed">
      {state.items.map((item) => <Paragraph>{item.label}</Paragraph>)}
    </Section>
  );
};

{condition && <Node />} works the way you’d expect too — anything falsy in the children is skipped.

Ids name themselves

You rarely need to write one. A node with no id gets named after whatever already says what it is — its heading, or the component that produced it:

<Section title="Fees and funding">   {/* fees-and-funding */}
  <Greeting />                        {/* greeting        */}
  <SignOff />                         {/* sign-off        */}
  <Paragraph>Plain.</Paragraph>       {/* paragraph       */}
</Section>

If two nodes derive the same name they get numbered: greeting, greeting-2. Two ids you wrote that collide are still an error, because that’s a typo rather than a repetition.

Names come from what a node is, never from where it sits. Insert a paragraph above another and nothing below it gets renamed.

When should you write one? When you want to pin an address — a node a request asks for by name, or one you expect to reference later. Otherwise leave it out.

The same .map() works whether the list is known now or only per request. See loops are published as loops below.

Reusable pieces

A component is a plain function, so a house-style wrapper is a plain function too. Components take children like anything else:

export const Letterhead: Section<{ children?: Yield }> = ({ children }) => (
  <Section id="letterhead" title="">
    <Image id="logo" src={logoDataUri} alt="Ashcroft Housing" />
    {children}
  </Section>
);

A shared hook is the other half of this. useSetPrompts sets prompts on whatever the calling component returns, so you can apply house style to a node the hook doesn’t own:

export function useHouseVoice() {
  useSetPrompts({
    systemPrompt: "You write for a housing association. Plain English, second person.",
  });
}

Any component that calls useHouseVoice() gets that system prompt on the node it returns — and can still override it with a prop.

Document projects

A document project ties a template to its style and preview data through one entrypoint, document.project.ts:

import { defineDocumentProject } from "docxcelerate/document";
import { documentTemplate } from "./document.tsx";
import { documentStyle } from "./document-style.ts";
import type { DocumentData } from "./types.ts";

export default defineDocumentProject<DocumentData>({
  id: "tenancy-renewal",
  name: "Tenancy Renewal",
  version: "1.0.0",
  template: documentTemplate,
  style: documentStyle,
  previewData: {
    residentName: "Avery Mitchell",
    propertyRef: "Flat 4, Ashcroft House",
  },
});

previewData is what the preview app resolves against. Keep it realistic — short names and placeholder cities hide the layout problems that real data will find.

What changes when you publish

Everything above assumes you hold the data. That’s true for a preview, a local pack, or a document you write on the spot.

Publishing to an engine is different. The artifact gets built once, against stand-in values for a request nobody has made yet, and then stored. Anything that depends on request data can’t be settled during that build — it has to travel to the engine and happen per document.

That sounds like it would change how you write components. Mostly it doesn’t: you keep writing ordinary ifs and ordinary .map()s, and the build works out what has to travel. There are three things worth understanding, and the build enforces all three rather than letting a wrong document reach a recipient.

Compute per document, not per build

Interpolating a value is fine while publishing. It becomes the token the engine substitutes:

<Paragraph id="greeting">Hello {state.name},</Paragraph>
// published as: "Hello {{data.name}},"

Computing on one isn’t, because the value doesn’t exist yet. Formatting a currency, totalling a list, comparing a date — those need a deriver, which the engine runs per document:

<Paragraph
  id="balance"
  derivers={[
    derive("currencyLabel", { output: "balanceLabel", inputs: [dataRef("balanceDue")] }),
  ]}
>
  Your balance is {"{{derived.balanceLabel}}"}.
</Paragraph>

useFormat and useDeriver compute during the build instead, which is what you want whenever the value is already known. Reach for a deriver when it isn’t.

Decisions travel, and you still write if

Write the conditional you’d write anywhere:

if (state.overdue) {
  return <Paragraph id="overdue">This account is overdue.</Paragraph>;
}

return <Paragraph id="clear">Nothing outstanding.</Paragraph>;

The build compiles that into a condition the engine evaluates per recipient, so both arms travel with the document. Ternaries and {cond && <Node />} compile the same way.

One distinction to know: an arm has to yield a node. A conditional that picks between two strings is a value, not a decision, and a value that varies per document is a deriver’s job.

This needs the Docxcelerate transform in your build — docxcelerateTransform() for Vite, or docxcelerateEsbuildTransform() for esbuild, both from docxcelerate/transform. Scaffolded workspaces already have it. Without it the conditional still runs, but it decides once at build time, for everybody.

Loops are published as loops

A branch has two arms and both can be published. A loop has as many as the request has entries, and nobody knows that number until a document is written — so the loop itself gets stored.

What you write is the ordinary .map():

const [visits] = useState((data: TenancyData) => data.visits);

return visits.map((visit) => <Paragraph id="visit">Visit: {visit.label}</Paragraph>);

With real data this is just Array.prototype.map. It walks the collection immediately, so previews show the actual repetition rather than a description of it. Publishing can’t walk it, so the stand-in intercepts the same call, runs the body once, and the loop travels to the engine intact.

You never write a {{ctx…}} token here. The entry your body was handed knows the path it stands for, so the reference writes itself.

Anything that has to look at the entries before the body can be written — .filter(), .sort(), .length, a for loop — can’t be answered while publishing. Those raise an error telling you to use a deriver, which runs where the data is.

Branches multiply

Publishing a branch stores both arms. Nesting branches multiplies what the document carries, so there’s a limit: branchLimit, 32 by default. Passing it fails the build rather than producing a quietly enormous artifact.

If you hit it, lift the decision into a deriver that returns one value — or raise the limit, if the document really is that conditional.


Edit this page on GitHub ↗ Read this page as Markdown