Skip to content

Quick start

BroadPaper ships as a set of packages. You need the editor in the app where people design reports, and the PDF package wherever you turn a saved template plus data into a document.

bash
pnpm add @broadpaper/core @broadpaper/blocks @broadpaper/renderer @broadpaper/editor @broadpaper/react
pnpm add @broadpaper/forme               # PDF output — no browser, runs anywhere

Every file below is complete. Copy them into a Vite React app in the order they appear and pnpm dev gets you a working designer; nothing is elided and no value is referenced before it is defined. If you would rather read than type, the demo is the same thing at full size.

1. Describe your data

The designer knows nothing about your domain. You tell it what fields exist:

ts
import type { DataSource } from "@broadpaper/core";

export const dataSources: DataSource[] = [
  {
    id: "client",
    label: "Client",
    schema: {
      firstName: { type: "string", label: "First name" },
      surname: { type: "string", label: "Surname" },
      dateOfBirth: { type: "date", label: "Date of birth" },
      isRetired: { type: "boolean", label: "Retired" }
    }
  },
  {
    id: "portfolio",
    label: "Portfolio",
    schema: {
      value: { type: "currency", label: "Portfolio value", currency: "GBP" },
      riskScore: { type: "number", label: "Risk score" },
      history: {
        type: "array",
        label: "Portfolio history",
        itemSchema: {
          type: "object",
          fields: { date: { type: "date", label: "Date" }, value: { type: "currency", label: "Value" } }
        }
      }
    }
  }
];

2. Give it something to draw with

Sample data is what the canvas previews with and what the checks panel reports on. It is keyed by data source id, and its shape has to match the schemas above.

ts
import type { SampleDataSet } from "@broadpaper/react";

export const sampleData: SampleDataSet[] = [
  {
    id: "example",
    label: "Alice Chen · Balanced",
    description: "A typical client",
    data: {
      client: { firstName: "Alice", surname: "Chen", dateOfBirth: "1978-03-14", isRetired: false },
      portfolio: {
        value: 431164,
        riskScore: 5,
        history: [
          { date: "2025-09-30", value: 402900 },
          { date: "2025-12-31", value: 411250 },
          { date: "2026-03-31", value: 420880 },
          { date: "2026-06-30", value: 425110 },
          { date: "2026-08-31", value: 431164 }
        ]
      }
    }
  }
];

/** The one set, by name — handy for previews and one-off renders. */
export const exampleData = sampleData[0]!.data;

3. Define a brand

A theme is the only place colours and fonts are named. Templates reference tokens, so the same saved JSON re-brands with one option change. Only id, name, colors and typography are required; everything else has a default.

ts
import type { Theme } from "@broadpaper/core";

export const brand: Theme = {
  id: "meridian",
  name: "Meridian Wealth",
  typography: { headingFont: "Inter, system-ui, sans-serif", bodyFont: "Inter, system-ui, sans-serif", baseFontSize: 11 },
  colors: {
    primary: "#1F3A5F",
    secondary: "#4A6FA5",
    accent: "#E0B04B",
    text: "#1A1D21",
    mutedText: "#6B7280",
    surface: "#FFFFFF",
    background: "#F5F6F8",
    border: "#E2E5EA"
  },
  // Anything under `meta` is addressable from a template as {{ theme.meta.* }}.
  meta: { company: "Meridian Wealth Management Ltd", regulator: "Authorised and regulated by the FCA" }
};

/** The same template, someone else's brand. */
export const whiteLabel: Theme = {
  ...brand,
  id: "hartwell",
  name: "Hartwell & Kane",
  colors: { ...brand.colors, primary: "#4A3728", secondary: "#8C6A4A", accent: "#C89F65" },
  meta: { company: "Hartwell & Kane Private Clients", regulator: "Authorised and regulated by the FCA" }
};

4. Mount the designer

template is whatever you last persisted, or undefined for a new one. onChange fires on every edit; onSave is yours to implement and is what the Save button and ⌘S call.

tsx
import { useState } from "react";
import { ReportDesigner } from "@broadpaper/react";
import type { ReportTemplate } from "@broadpaper/core";
import { dataSources } from "./data-sources";
import { sampleData } from "./sample-data";
import { brand, whiteLabel } from "./themes";

export function TemplateEditor({ initial }: { initial?: ReportTemplate }) {
  // The draft is the current, unsaved document. Hold it if you want to render a
  // preview or an export beside the designer; `onSave` gets the same value.
  const [draft, setDraft] = useState<ReportTemplate | undefined>(initial);

  async function save(template: ReportTemplate) {
    // Throwing here is how you get "Save failed" with your message in the toolbar.
    const res = await fetch("/api/templates/investment-review", {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(template)
    });
    if (!res.ok) throw new Error(`Could not save (${res.status})`);
  }

  return (
    <div style={{ height: "100vh" }}>
      <ReportDesigner
        template={draft}
        dataSources={dataSources}
        sampleData={sampleData}
        theme={brand}
        themes={[brand, whiteLabel]}
        onChange={setDraft}
        onSave={save}
      />
    </div>
  );
}

Or without a framework. createReportDesigner mounts into an element and keeps running until you destroy it — so destroy() belongs in your teardown, not on the next line:

ts
import { createReportDesigner } from "@broadpaper/editor";
import type { ReportTemplate } from "@broadpaper/core";
import { dataSources } from "./data-sources";
import { sampleData } from "./sample-data";
import { brand } from "./themes";

const host = document.getElementById("designer")!;
let draft: ReportTemplate | undefined;

const designer = createReportDesigner(host, {
  dataSources,
  sampleData,
  theme: brand,
  onChange: (template) => {
    draft = template;
  },
  onSave: async (template) => {
    await fetch("/api/templates/investment-review", {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(template)
    });
  }
});

// Read the current document at any time.
export const currentTemplate = () => designer.getTemplate();

// Tear down when the page or view goes away — not immediately after mounting.
window.addEventListener("beforeunload", () => designer.destroy());

The editor injects its own stylesheet, so there is nothing else to import.

5. Render a PDF

ts
import { renderPdfPaginated } from "@broadpaper/forme";
import { createRegistry } from "@broadpaper/blocks";
import type { ReportTemplate, ReportData } from "@broadpaper/core";
import { dataSources } from "./data-sources";
import { brand } from "./themes";

export async function renderReport(template: ReportTemplate, data: ReportData) {
  const { pdf, pages, warnings } = await renderPdfPaginated({
    template,                   // the saved JSON
    registry: createRegistry(), // plus any custom blocks: createRegistry([myBlock])
    data,                       // runtime data, keyed by data source id
    theme: brand,               // any theme — the template re-brands
    dataSources,
    metadata: { title: "Investment review", author: brand.name }
  });
  if (warnings.length) console.warn("[broadpaper]", warnings);
  return { pdf, pages };        // pdf is a Uint8Array
}

data is the real thing, shaped like the sample data above:

ts
import { writeFile } from "node:fs/promises";
import type { ReportTemplate } from "@broadpaper/core";
import { renderReport } from "./render";
import { exampleData } from "./sample-data";

const template: ReportTemplate = await (await fetch("/api/templates/investment-review")).json();
const { pdf, pages } = await renderReport(template, exampleData);
await writeFile("investment-review.pdf", pdf);
console.log(`${pages} pages`);

The engine is WebAssembly, so the same call works in Node, on the edge, in a Worker or in the browser. See Browserless PDF for what it does and does not guarantee, and how to point the canvas at the same engine so it breaks pages exactly where the file does. If you need the browser's own text shaping instead, @broadpaper/pdf drives headless Chromium and ships as a service.

6. Preview in the browser

tsx
import { ReportPreview } from "@broadpaper/react";
import type { ReportTemplate } from "@broadpaper/core";
import { dataSources } from "./data-sources";
import { exampleData } from "./sample-data";
import { brand } from "./themes";

export function Preview({ template }: { template: ReportTemplate }) {
  return <ReportPreview template={template} data={exampleData} theme={brand} dataSources={dataSources} />;
}

The preview runs the same resolve → measure → paginate pipeline as the PDF, so page breaks, repeated table headers and page numbers match what you will get in the file.