Browser component · One mount call
JavaScript report designer
createReportDesigner(element, options) is the product. The React and Angular packages are wrappers
over that one call, which is also how you mount it from Vue, Svelte, Lit, a web component, or a page with no
framework on it at all.
pnpm add @broadpaper/core @broadpaper/blocks @broadpaper/renderer @broadpaper/editor @broadpaper/forme
An element, an options object, and an instance with a destroy().
That is all of it. Everything the designer can do is a key on the options object or a method on the instance it returns; there is no context provider, no store to install and no stylesheet to import — the editor injects its own.
The instance carries getTemplate(), setTemplate(), setTheme(),
setMode(), undo(), redo(), validate(),
exportPdf(), print(), getLayout() and
registerBlock(), so a toolbar of your own is a row of buttons calling methods.
update(options) takes a partial set, which is how a wrapper in any framework forwards changed
props without remounting.
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/${template.id}`, {
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 view goes away — not on the next line.
window.addEventListener("beforeunload", () => designer.destroy()); Two wrappers ship. The third one is fifteen lines.
@broadpaper/react and @broadpaper/angular exist because most hosts are one or the
other — not because the editor needs them. There is no Vue, Svelte or Lit package, and rather than pretend
otherwise, here is the whole of what one would be.
<script setup lang="ts">
// There is no @broadpaper/vue package. This is the whole of one.
import { onMounted, onBeforeUnmount, ref } from "vue";
import { createReportDesigner, type ReportDesignerInstance } from "@broadpaper/editor";
import type { ReportTemplate } from "@broadpaper/core";
import { dataSources } from "./data-sources";
import { brand } from "./themes";
const host = ref<HTMLElement | null>(null);
const emit = defineEmits<{ (e: "change", template: ReportTemplate): void }>();
let designer: ReportDesignerInstance | null = null;
onMounted(() => {
designer = createReportDesigner(host.value!, {
dataSources,
theme: brand,
onChange: (template) => emit("change", template)
});
});
onBeforeUnmount(() => {
designer?.destroy();
designer = null;
});
</script>
<template>
<!-- The editor fills its container, so the container needs a height. -->
<div ref="host" style="height: 100vh" />
</template> Three things a wrapper has to get right
- Mount once. Create the designer when the element exists and never again; forward later changes
through
update()rather than by recreating it. - Destroy on teardown. The instance holds listeners, an observer and a measurer. In a single-page application, leaking one per navigation is noticeable by the third.
- Do not echo your own template back. If you feed the template you received from
onChangestraight back in as a prop, you have built a loop. The React package handles this by ignoring an object the editor itself last emitted; a wrapper of your own should do the same or simply treat the template as uncontrolled.
If you are on React or Angular, use the packages — they already do all three, and the Angular one also keeps the editor out of your change detection.
A vector PDF, made in the tab.
The PDF engine is Rust compiled to WebAssembly — no headless Chromium, no server, nothing uploaded. It draws selectable text, vector charts and the fonts your theme declares, and it returns the page count with the bytes.
The same call runs in Node. BroadPaper's own paginator decides the page breaks before either of them draws anything, so a document rendered in a browser and the same document rendered on a server break in the same places.
import { renderPdfPaginated } from "@broadpaper/forme";
import { createRegistry } from "@broadpaper/blocks";
import { downloadBlob } from "@broadpaper/renderer";
export async function download(template, data, theme, dataSources) {
const { pdf } = await renderPdfPaginated({
template,
data,
theme,
dataSources,
registry: createRegistry()
});
// pdf is a Uint8Array; nothing has left the tab at any point.
downloadBlob(new Blob([pdf], { type: "application/pdf" }), "report.pdf");
} Or no file at all.
The read-only viewer renders the same template as a page-shaped web document with charts a reader can point at — the half of a report a PDF cannot do. It is the same pipeline, so what is on screen is what would be in the file.
import { createReportViewer } from "@broadpaper/renderer";
import { createRegistry } from "@broadpaper/blocks";
// The same template as a page a reader can read: page-shaped, zoomable,
// with charts they can point at. It fills its host and scrolls inside
// itself, so the host needs a height or it collapses to its toolbar.
const viewer = createReportViewer(document.getElementById("report")!, {
template,
data,
theme,
dataSources,
registry: createRegistry(),
fit: "width",
onPageChange: (page) => history.replaceState(null, "", `#page-${page}`)
}); What your bundler needs, in full.
Only the PDF engine needs anything, and only because it is WebAssembly. The editor, the renderer and the block catalogue are ordinary ES modules.
| Vite | vite-plugin-wasm, build.target: "esnext" for the top-level await, and
optimizeDeps.exclude: ["@formepdf/core"] — pre-bundling moves the module away from the
.wasm file it finds by import.meta.url.
|
|---|---|
| webpack | experiments.asyncWebAssembly. |
| Next.js |
The same webpack flag, plus keeping the @broadpaper/forme import out of anything that renders
on the server, and the designer behind a client-only dynamic import.
|
| Angular CLI | Nothing. The package resolves to the engine's worker build, which is what Angular's builder wants. |
| Everybody advice | Load the designer and the engine behind a dynamic import. Seven megabytes of WebAssembly in the first chunk makes every page in the application pay for the one screen that needs it. |
Is this for you?
A good fit
- A Vue, Svelte, Lit or vanilla host that still needs a real document designer
- A micro-frontend or embedded widget that cannot assume the page's framework
- Teams who want the whole document pipeline in the browser — design, preview and PDF — with no service to run
- Anywhere the integration has to be auditable: one call, one options object, one instance
Probably overkill
- A one-page document drawn at fixed coordinates, where jsPDF is smaller and free
- Exports that are really data — a CSV is cheaper for everybody
- Anywhere the browser's own print stylesheet is already producing what people want
Questions
- Is there a Vue or Svelte package?
- No, and there is no need for one to use it.
createReportDesigner(element, options)returns an instance withdestroy(), so a wrapper is a mounted hook, an unmounted hook and about fifteen lines — the example on this page is the whole thing. The React and Angular packages exist because those two account for most hosts, not because the editor needs a framework. - Does the designer bring a framework into my bundle?
- Yes, and it is worth knowing before you start. The editor is React internally, so
@broadpaper/editordepends on React and React DOM — a Vue or Svelte host installs them transitively and carries them in whichever chunk the editor lands in. Put the designer behind a dynamic import, which you want anyway because the PDF engine is around 7 MB, and no page that does not open it pays for either. - Can I generate the PDF entirely in the browser?
- Yes.
@broadpaper/formeis a Rust engine compiled to WebAssembly. It runs in the tab, produces a vector PDF with selectable text and embedded fonts, and needs no server and no headless Chromium. The same package and the same call run in Node when you want the file produced somewhere else. - What does my bundler need?
- Vite wants
vite-plugin-wasm,build.target: "esnext"and@formepdf/coreexcluded from dependency pre-bundling. Webpack wantsexperiments.asyncWebAssembly. Angular wants the engine's worker build, which the package resolves for you. Nothing else in the SDK needs build configuration at all. - Do I have to use your blocks?
- No.
defineBlockadds your own, with a declarative inspector and a pure render function that returns virtual nodes. Yours drag, bind, theme and paginate exactly like the built-in ones, because the built-in ones are defined the same way. - Is it a reporting server, or a hosted service?
- Neither. It is a set of npm packages you install, plus an optional render service that is a container you run. There is no BroadPaper endpoint your data passes through and no telemetry.
One call, in your own page.
The demo is the editor with nothing around it. The quick start has the framework-free mount beside the React one.