Packages

PDF Reports

Define a PDF report as data — a sandboxed code step for the values, a JSON layout for the page — and render it on a server or in the browser.

@helix-hq/pdf-report turns a report into data you can store, version and generate, instead of a React component someone has to redeploy to change.

It owns rendering and authoring, and nothing else: no database, no tRPC, no auth, no scheduling, no delivery. That is what makes it droppable into any backend — or none at all, since it renders in the browser too.

pnpm add @helix-hq/pdf-report

Peers you supply: react and react-dom. That is all a rendering install needs — no UI library, no Tailwind, no backend.

The template editor additionally wants @helix-hq/design-system and react-resizable-panels. Both are optional peers, so a render-only install never pulls them in:

pnpm add @helix-hq/design-system react-resizable-panels

The two tiers

A template is a code step and a layout, and the two schemas between them are the whole contract:

type ReportTemplate = {
  inputSchema: JSONSchema; // what the report is handed
  code: string; // TypeScript over `input`, returning display values
  outputSchema: JSONSchema; // what the code produces, and what `spec` may bind to
  spec: ReportSpec; // presentation only
  demoInput: unknown; // sample input for the editor preview
};

Rendering runs that contract end to end:

input → validate(inputSchema) → run code → validate(outputSchema) → bind into spec → PDF

Why split them. The alternative — components that compute their own values — means a table column that can coalesce dot-paths, sum fields, subtract one from another, apply a scale, match rules and format the result. That is a small untyped programming language expressed in JSON: less capable than code, and harder to read than a template. Here, aggregation, filtering, grouping, sorting and formatting are ordinary TypeScript, and the components place values and compute nothing.

The code runs in @helix-hq/code-executor's QuickJS WASM sandbox: no network, no filesystem, bounded CPU and memory. It runs the same way on the server and in the browser, so a preview matches what gets delivered.

Rendering

Two entry points, one pipeline.

import { renderReportToBuffer } from '@helix-hq/pdf-report/server';

const pdf = await renderReportToBuffer(template, {
  input: { reportTitle: 'Fleet health', devices },
  branding: { title: 'Fleet report', generatedAt: new Date().toUTCString() },
});
// → Uint8Array

Both take PrepareReportOptions: input (defaults to the template's demoInput), branding, and limits for the sandbox. Need the intermediate values — to debug a template, or to render something other than a PDF? Call prepareReport(template, options) directly; it returns { spec, data, logs }.

Branding is the caller's, not the author's

renderReportToBuffer rewrites every Page element in the spec to ReportPage and stamps your branding onto it. A template author cannot drop the header and footer, and cannot set them either — they always come from whoever renders.

type ReportBranding = {
  title?: string; // shown top-right of every page
  subtitle?: string; // secondary line under the title
  generatedAt?: string; // human-readable timestamp for the footer
  footerNote?: string; // overrides the default "Generated <generatedAt>"
};

The component catalog

The catalog is the single source of truth for what a template may say. Every component is declared with a zod props schema, slots, a description and an example — and that one declaration drives typed components, spec validation, editor completion, and the AI system prompt.

ComponentTakes
ReportPageBranded page with a repeating header/footer. Injected automatically.
SectionTitled panel that groups content.
MetricGrid / MetricCardA KPI tile; value is already formatted.
DataTableheaders: string[], rows: string[][], and the rowColors your code decided.
Callouttext and tone. Empty text renders nothing — that is how a template hides one.
BarChart / LineChart / PieChartseries: { label, value }[], pre-aggregated.
KeepTogetherStops its children being split across a page break.

These sit on top of the stock @json-render/react-pdf catalog — Document, Page, View, Row, Column, Heading, Text, Image, Link, Table, List, Divider, Spacer, PageNumber — which stays available.

Validation

validateReportSpec(spec) runs before anything reaches react-pdf, and checks:

  • unknown component names, reported with the available set;
  • props against each component's zod schema, including nested shapes;
  • every {"$state": "/x"} binding against outputSchema, so a typo'd path is an error rather than a silently empty cell;
  • structural problems — missing root, dangling child references.

What it deliberately does not check is the shape of a bound prop: that value only exists at render time. It verifies the path is produced, not what it holds.

The editor

ReportTemplateEditor is the authoring UI: schema, code, layout and live preview panes, with Monaco typed from inputSchema so an author gets completion on input.devices[0].faults.

'use client';

import { ReportTemplateEditor } from '@helix-hq/pdf-report/editor';

<ReportTemplateEditor
  defaultValue={template}
  renderMode="client" // 'server' proves what a delivered document contains
  theme="light"
  onChange={setTemplate} // fires when every pane parses cleanly
  onError={setError}
/>;

It is a Tailwind v4 UI, and it ships two prebuilt stylesheets you must import — one for the classes, one for the tokens they read:

@import '@helix-hq/design-system/globals.css'; /* theme tokens */
@import '@helix-hq/pdf-report/editor.css'; /* the editor's classes */

Do not try to generate these yourself with @source. Tailwind does not scan node_modules, so pointing it at the installed package silently produces nothing and every pane renders unstyled. That is why the stylesheet is compiled at publish time and shipped.

@monaco-editor/react loads Monaco from a CDN by default. Call loader.config({ paths: { vs: … } }) in your app to self-host it.

Wiring into Next.js

// next.config.ts
transpilePackages: ['@helix-hq/pdf-report'],
serverExternalPackages: ['@react-pdf/renderer', '@json-render/react-pdf'],

serverExternalPackages is not optional. defineRegistry ships only from @json-render/react-pdf's root, which builds four React contexts at module scope; under Next's react-server condition createContext does not exist and the render throws. Externalising the package resolves it outside those conditions — the same wiring upstream's own example ships (json-render#317).

Then provide the render route. It is yours, because it is where auth, rate limits and branding are decided:

// app/api/pdf-report/route.ts
export const runtime = 'nodejs';

export const POST = async (request: Request) => {
  const { resolveReportTemplate } = await import('@helix-hq/pdf-report');
  const { renderReportToBuffer } = await import('@helix-hq/pdf-report/server');

  const body = await request.json();
  const template = resolveReportTemplate(body.template);

  const pdf = await renderReportToBuffer(template, {
    input: body.input,
    branding: { title: 'Fleet report', generatedAt: new Date().toUTCString() },
  });

  return new Response(Buffer.from(pdf), {
    headers: { 'content-type': 'application/pdf' },
  });
};

The editor preview and fetchReportPdf both post here — /api/pdf-report by default, overridable with the endpoint prop, so the package never hardcodes one app's routing.

Storing templates

The package stores nothing, on purpose: a template is five JSON-serialisable fields, and where they live is your decision. Postgres, SQLite, S3, a git repo, a CMS — all fine.

Helix's own console keeps them in Postgres. That code is in the app, not in this package: a report_template table with a column per part, so each pane saves on its own and a stale pane cannot overwrite a fresh one, plus a tRPC router for CRUD. Copy the shape if it suits you.

Template code runs in a sandbox, but a template is still privileged content — whoever can write one controls what every reader's report says. Gate authoring behind an admin role, as Helix does.

AI authoring

@helix-hq/pdf-report/ai exports reportAuthoring and reportCapabilities, which describe the whole vocabulary — component names, prop shapes, the two-tier model — as an AiCapability. Compose them into an assistant and it can generate a template from a prompt, or patch an existing one:

import { composeAssistant } from '@helix-hq/ai-kit';
import { reportCapabilities } from '@helix-hq/pdf-report/ai';

const assistant = composeAssistant(reportCapabilities(), {
  intro: 'You author PDF report templates.',
});

Examples

License

MIT.