How to let users design their own PDF reports in a React application

The interesting problems here are not the editor. They are the trust boundary between what a user may lay out and what your application will actually resolve, and what happens to a saved layout when the schema underneath it moves.

Last checked

The shape of the system

Whatever you build or buy, user-designed reports decompose the same way. Five moving parts, and the interesting ones are not the visual editor everybody pictures first.

PartOwnsTrust
SchemaWhat fields exist and what they are calledYours, built per request
DesignerArranging those fields on a pageRuns in the user's browser. Convenience, never enforcement
Template storeThe saved layout, per tenantYours. Validate on the way in
RendererTemplate + data → a fileYours, wherever you run it
Data loaderDeciding what this record actually containsYours, and the real security boundary

The template is inert. It is a description of where things go, with references to fields by name; it has no credentials, runs no code, and produces nothing until your server hands it data. Design around that and most of the scary questions evaporate.

1. The data contract

A data source is a typed description of fields, with the labels people will see. It is the only thing the designer knows about your domain: no table names, no endpoints, no records, no way to address anything you did not describe.

Four rules, learned the expensive way.

Build it per request, not per application

The temptation is one exported constant. Resist it. Whether a field appears in the picker should be the same decision as whether the user may read it, made in the same place.

data-sources.ts Your server, or your React loader
// Built per request, from what this user may actually read.
// The designer renders what you give it; it cannot know what it
// should not have been given.
import type { DataSource } from "@broadpaper/core";

export function dataSourcesFor(user: User): DataSource[] {
  const client: DataSource = {
    id: "client",
    label: "Client",
    schema: {
      name: { type: "string", label: "Client name" },
      reference: { type: "string", label: "Client reference" },
      reviewDue: { type: "date", label: "Review due" }
    }
  };

  // Not a flag on a field. A field that is not declared does not
  // exist as far as the designer, the picker and the validator are
  // concerned — and a template cannot bind to it.
  if (user.can("read:fees")) {
    client.schema.annualFee = { type: "currency", label: "Annual fee", currency: "GBP" };
  }

  return [client, portfolio];
}

Omit, do not disable. A field that is not declared does not exist as far as the designer, the field picker, the autocomplete and the validator are concerned, and a saved template cannot bind to it — so “can they see it?” and “can they bind to it?” cannot drift apart.

Label for the reader, not for the database

clnt_ref_1 is not a label. Whatever you put in label is what somebody will search the picker for and what they will reason about, and a bad one causes the worst class of bug in this whole system: a document that is confidently, plausibly wrong because somebody bound the field next to the one they meant.

Type it precisely

currency rather than number is not pedantry — it is what makes a money column format correctly with nobody writing a pipe, and what makes a total in a table right by default. percent carries the “stored as 0.103, printed as 10.3%” convention so nobody has to remember it. An array of objects exposes each field as a list as well, which is what tables bind to and what sum() takes.

Do not declare what you will not resolve

Every field in the schema is a promise that the render path will supply it. A field that is present at design time and missing at render time produces a document with a hole in it — and the person who finds the hole is a client, on a Friday.

2. Storing templates

A template is JSON. It is stable, deterministic in key order, round-trips losslessly through JSON.stringify, and is typically 20–60 KB for a multi-page report. Which means the storage design is boring, and boring is the correct outcome.

schema.sql Any database
// One row per tenant per document type. The template is data.
create table report_template (
  id            uniqueidentifier not null primary key,
  tenant_id     uniqueidentifier not null,
  kind          varchar(64)      not null,   -- 'statement', 'review', 'certificate'
  name          nvarchar(200)    not null,
  template      nvarchar(max)    not null,   -- the JSON the designer saved
  schema_hash   char(64)         not null,   -- which schema it was designed against
  updated_at    datetime2        not null,
  updated_by    uniqueidentifier not null
);

create unique index ux_template_tenant_kind_name
  on report_template (tenant_id, kind, name);

Two columns there are worth explaining. schema_hash records which version of your declared fields the design was made against, which is what lets you find the templates affected by a schema change without opening every one. updated_by matters because a layout is now something a person changed, and “who moved the total” is a question somebody will eventually ask you.

Accepting a template is two checks, and they belong on the server rather than in the React app that sent it:

templates.ts Your API
import { migrateTemplate, validateTemplate } from "@broadpaper/core";
import { createRegistry } from "@broadpaper/blocks";

/**
 * Templates arrive from a browser, so they are untrusted input like any
 * other body. Two checks, in this order, and neither is optional.
 */
export async function saveTemplate(req: Request, user: User) {
  if (!user.can("design:reports")) throw new Forbidden();

  // 1. Bring an older save forward. The editor migrates on load too,
  //    but the API should never store a version it did not understand.
  const { template } = migrateTemplate(await req.json());

  // 2. Check every binding against the schema THIS user designs against.
  //    A template referring to a field they cannot read is a 422, not a
  //    blank space on a document somebody sends to a client.
  const dataSources = dataSourcesFor(user);
  const result = validateTemplate({ template, registry: createRegistry(), dataSources });
  if (!result.ok) return problem(422, result.errors);

  await db.upsertTemplate({
    tenantId: user.tenantId,
    kind: req.params.kind,
    template,
    schemaHash: hashOf(dataSources),
    updatedBy: user.id
  });
}

Validation without data is the property that makes this work. validateTemplate answers “is this design sound?” — every binding resolves, every block is registered, every expression parses and type-checks — for every report the template will ever produce. It is a different question from “does this client's data fill it in”, which only a render can answer, and keeping the two apart is what stops you shipping a check that means neither.

3. Permissions

There are three distinct rights here and products routinely collapse them into one, which is how a support agent ends up able to change what every client's statement says.

  • Design — may open the editor and save templates. Usually a small number of people per tenant.
  • Run — may produce a document from a template. Usually most of the tenant.
  • Read — which fields this person may see at all, which is what shapes the schema above.

Enforce all three at your API. The designer's feature flags are a user-interface decision — they narrow what an audience has to look at, not what the system permits — and anything enforced only in an interface is not enforced. Hiding the expression editor is good product design; it is not a security control.

Worth saying plainly: with BroadPaper, expressions users write are parsed by a hand-written tokeniser and Pratt parser and evaluated by a tree-walking interpreter. There is no eval, no Function and no compilation to JavaScript anywhere in the product, and property access reads own data properties only. If you are building this yourself, that is the bar — a formula language backed by new Function is a remote code execution vulnerability with a friendly name.

4. Per-tenant layouts and branding

Keep them separate. They look like the same problem and they are not.

  • Layout is per template. One row per tenant per document type. New tenants get a copy of a default set, which means your defaults have to be good, because most tenants will never change them.
  • Brand is per tenant. A template that names theme tokens rather than fixed colours re-brands with one option, so a shared design still looks like theirs. Fonts are declared once in the theme, and — this matters — the same font files should both measure the layout and draw the file, or the preview a user approved is not the document they get.

The reason to resist putting the brand in the template is the tenant who changes their colours. If the brand lives on the tenant, that is one update. If it is baked into six templates, it is a migration and a support ticket.

5. Where to render

You will want both, and the rule is simpler than it looks: render in the browser when a person is waiting, render on the server when the file is a record.

In the browserOn the server
Good forPreview, ad-hoc download, “show me before I send it”Scheduled runs, emailed documents, archives, anything audited
LatencyInstant; the data is already thereA queue and a round trip
Data exposureNothing leaves the deviceData stays server-side, which some compliance regimes require
Watch out forBundle size — put the engine behind a dynamic importThe engine is synchronous; a long document belongs on a worker thread, not the request thread
render.ts Your API
import { renderPdfPaginated } from "@broadpaper/forme";
import { createRegistry } from "@broadpaper/blocks";

/**
 * The server's render path. Note what is NOT taken from the request:
 * the data. The caller names a record; the server decides what that
 * record contains and whether this user may have it.
 */
export async function renderReport(user: User, kind: string, recordId: string) {
  const row = await db.template(user.tenantId, kind);          // their template
  const data = await loadRecord(user, recordId);               // their data, their permissions
  const theme = await themeFor(user.tenantId);                 // their brand

  const { pdf, pages } = await renderPdfPaginated({
    template: row.template,
    data,
    theme,
    dataSources: dataSourcesFor(user),
    registry: createRegistry(),
    now: new Date(),            // pass a fixed clock to make the output reproducible
    locale: "en-GB",
    currency: "GBP",
    metadata: { title: row.name, author: theme.name, lang: "en-GB" }
  });

  await audit.record({ user, kind, recordId, pages, templateVersion: row.updatedAt });
  return pdf;
}

The important line in that function is the one that is missing. The request names a record; it does not carry the data. A render endpoint that accepts data from its caller is an endpoint that will eventually produce a document containing whatever the caller sent, under your letterhead.

One more thing worth designing in early: pass a fixed clock. With now supplied, the same template, data and theme produce the same bytes — which makes a document testable, and makes “re-issue exactly what we sent in March” a real feature rather than an approximation.

6. Versioning and schema drift

This is the part that bites in year two, and it is the single best reason to choose something that validates templates without data.

Saved layouts outlive the schema they were written against. You will rename a field, split one into two, or drop one that turned out to be a mistake — and somewhere there are forty templates that reference it. Without a check, the failure surfaces as an empty space on a document, months later, for the tenant who used that field most.

check-templates.ts A nightly job, or a migration step
// Run after any change to what you declare. It needs no data, so it can
// run over every stored template in a migration or a nightly job.
for (const row of await db.allTemplates()) {
  const dataSources = dataSourcesForTenant(row.tenantId);
  const { errors, warnings } = validateTemplate({
    template: row.template,
    registry: createRegistry(),
    dataSources
  });
  // 'unknown-binding' is the one that matters: a design pointing at a
  // field that no longer exists. Tell the tenant before their month end
  // does.
  if (errors.length) await flagForReview(row, errors);
}

Three habits that make this survivable:

  • Add, do not rename. Treat declared fields as a published API, because that is what they are. Add the new field, leave the old one resolving, and retire it once nothing references it — which the check above can tell you.
  • Keep revisions. A layout is now something a person edits, so it needs the same treatment as any other edited document: history, a diff, and a restore that writes a new revision rather than overwriting one. A restore that destroys what it replaced is not a restore.
  • Migrate on load, validate on save. The editor brings an old template forward when it opens one; your API should refuse to store a version it does not understand. Both directions, so a template can never be stored in a state nothing can read.

Build or buy

Everything above is architecture you need whichever way you go. What differs is how much of it you write.

Building it is reasonable when reports are the product — when the document is what your customers are buying, and the editor being exactly right is worth a team. Be clear-eyed about the scope, though: a document model, an editor with undo and keyboard support, a binding layer with a picker and type checking, validation, versioning and a paginator that agrees with your renderer. The paginator is the one people underestimate, because it is invisible until it is wrong.

BroadPaper is that list, finished, as an embeddable component. You keep the parts of this guide that are genuinely yours — the schema, the permissions, the storage, the data loader — and the SDK owns the editor, the document model, measurement, pagination, theming and the PDF. The React page has the integration; the quick start is about thirty lines.

Questions

How do I stop users binding to data they should not see?
Build the schema per request from the caller's own permissions, and never send a field to the designer that the same user could not read through your API. The designer is a user interface, so it cannot be the enforcement point; the enforcement is that your render path resolves fields from data your server chose to load for that user.
Where should templates be stored?
In your own database, as JSON, with the tenant and the document type on the row. They are ordinary data — deterministic in key order, losslessly round-tripped by JSON.stringify, typically 20–60 KB — so a jsonb/nvarchar(max) column and an index on tenant plus type is the whole schema.
What happens when the schema changes?
Saved templates outlive the fields they were written against, so you need a way to find the ones that no longer fit. validateTemplate answers that without any data at all — run it across stored templates after a schema change and you get a list of designs to fix before a customer finds them.
Should the PDF be rendered in the browser or on the server?
In the browser when the user is watching and the data is already in the tab: it is instant and nothing leaves the device. On the server for anything scheduled, emailed, archived or audited, because the artefact of record should be produced by code you control. The engine is the same in both, so the page breaks do not move between them.
How do I handle per-customer branding?
As a separate concern from layout. A template that names theme tokens rather than fixed colours re-brands with one option, so one design serves every tenant and each still gets their own colours, fonts and logo. Keep the brand on the tenant, not in the template.
Do users need to understand expressions?
No, and most should not meet them. Binding a field is dragging it. Arithmetic is authored by choosing steps — a starting value and operations applied in order — rather than by typing a formula, and the free-form expression editor is a feature flag you can leave off.

The editor is the part you can try in a minute.

Everything above is architecture you would need whichever designer you used. This is the one we build: open it, bind a field, export a PDF — no account, nothing uploaded.