---
title: "Generate branded PDF factsheets from fund JSON with Node"
description: "Choose a PDF renderer, lay out key facts, holdings, exposures, returns, the 1–7 risk scale and fees, add the required disclaimers, and batch-generate by ISIN."
canonical: https://fundfactsapi.com/blog/generate-pdf-factsheets-from-json
published: 2026-09-09
updated: 2026-09-09
publisher: FundFacts API
---

# Generate branded PDF factsheets from fund JSON with Node

> Choose a PDF renderer, lay out key facts, holdings, exposures, returns, the 1–7 risk scale and fees, add the required disclaimers, and batch-generate by ISIN.

Most teams that consume fund data eventually have to produce a document: a one-page factsheet in the firm's colours for a client portal, an adviser pack, a model-portfolio appendix. The fund house publishes its own factsheet, but it is in their brand, on their schedule, and one PDF per share class. This tutorial goes the other way from [our post on turning factsheets into JSON](https://fundfactsapi.com/blog/fund-factsheet-automation): it starts from the structured JSON [FundFacts API](https://fundfactsapi.com/) returns for an ISIN and renders a branded one-page PDF, then does it for a list.

## Choosing a renderer

There are two families of tools. Either you write HTML and CSS and let a headless browser print it, or you place text and shapes on a page with a PDF library.

| | HTML → PDF (Playwright, Puppeteer) | PDF library (pdf-lib, PDFKit, pdfmake) |
| --- | --- | --- |
| Layout effort | Low: CSS grid, flexbox, fonts you already use | High: coordinates, manual wrapping |
| Charts | Any HTML/SVG chart, or plain CSS bars | Draw rectangles yourself or embed images |
| Fidelity to your web brand | Exact, same stylesheet | Reimplemented |
| Runtime footprint | A browser binary (~300 MB), slower cold start | Small, fast, runs anywhere |
| Deterministic output | Mostly; font loading and page breaks need care | Fully |
| Best for | Batches on a server or CI job, rich layouts | Serverless, tight templates, embedding into existing PDFs |

For a one-page factsheet with bars and a brand stylesheet, HTML → PDF is the shorter road, and it is what we use below with Playwright. If you have to run inside a constrained serverless function, the PDF library route is the fallback; the section layout is identical.

## What goes on the page

A factsheet is a fixed set of panels. The API's schema maps to them directly, which is the whole point of starting from JSON rather than from someone else's PDF.

| Panel | Fields |
| --- | --- |
| Header | `name`, `isin`, `data.keyFacts.currency`, `data.profile.category`, `data.dataAsOf` |
| Key facts | `keyFacts.assetClass`, `keyFacts.aum`, `keyFacts.inception`, `keyFacts.distribution`, `keyFacts.holdings`, `keyFacts.manager`, `benchmarkName` |
| Objective | `investmentObjective` (trim to three or four lines) |
| Top 10 holdings | `topHoldings[0..9]` as name and weight |
| Sector and geography | `sector` and `geography` as horizontal bars |
| Calendar returns | `calendarReturns.years` with `.fund` and `.benchmark` |
| Risk indicator | `riskRating` on a 1–7 scale |
| Fees | `headlineMetrics.ter` |

Two layout decisions save trouble. Fix the page to A4 (or Letter for US readers) with `@page { size: A4; margin: 12mm }` and design the panels to fit without a page break; a factsheet that spills to page two looks broken. And render bars with plain CSS widths driven by the weight, not a charting library: it is deterministic, needs no JavaScript at print time, and prints identically every run.

The risk indicator deserves a proper scale, not just a number. Draw seven boxes and fill the one matching `riskRating`, with "Lower risk" and "Higher risk" labels at the ends, because that is the form readers know from the KID. The [SRRI and SRI explainer](https://fundfactsapi.com/blog/srri-risk-indicator-explained) covers what the number means and why it can differ between documents.

## The disclaimers a fund document must carry

Your PDF is a marketing or client communication about a fund, and the conventions for those are well established. At minimum, every page should state:

- **Past performance** is not a reliable indicator of future results. Put this next to any returns panel, not only in the footer.
- **Data as-of date.** Print `data.dataAsOf` prominently, and the generation timestamp separately. Portfolio figures describe the fund at a date, not today.
- **Source.** Figures are taken from the documents published by the fund's management company (factsheet, KID, holdings files), reproduced without warranty. Do not imply the fund house produced or approved your document.
- **Not advice.** The document is information, not a recommendation to buy, sell or hold; readers should consult the KID and prospectus before investing.
- **Fees.** Say what the fee figure is ([TER or ongoing charges](https://fundfactsapi.com/glossary/ongoing-charges)) and that transaction costs and platform fees are excluded.
- **Currency and rounding.** Holdings weights are as published and may not sum to 100 because of cash, derivatives and rounding.

If you distribute in the EU or UK, your compliance team will have house wording for these; give them a single template block to edit rather than scattering text across the layout.

## The template

Keep the template as a function from the API response to an HTML string. It is easy to test, easy to preview in a browser, and the same function serves the client portal's web view if you want one.

```typescript
// factsheet/template.ts
type Fund = { isin: string; name: string; generatedAt: string; data: any };

const esc = (s: unknown) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c] as string));
const bars = (rows: { label: string; weight: number }[] = []) =>
  rows.slice(0, 8).map((r) => `<div class="bar"><span>${esc(r.label)}</span><i style="width:${Math.min(r.weight, 100)}%"></i><b>${r.weight.toFixed(1)}%</b></div>`).join("");

export function factsheetHtml({ isin, name, generatedAt, data: d }: Fund): string {
  const kf = d.keyFacts ?? {};
  const years: string[] = d.calendarReturns?.years ?? [];
  const risk = Number(d.riskRating) || 0;
  return `<!doctype html><html><head><meta charset="utf-8">
  <link rel="stylesheet" href="file://${process.cwd()}/factsheet/brand.css"></head><body>
  <header><h1>${esc(name)}</h1><p>${esc(isin)} · ${esc(kf.currency)} · ${esc(d.profile?.category)} · Data as of ${esc(d.dataAsOf)}</p></header>
  <section class="grid">
    <div class="panel"><h2>Key facts</h2><dl>
      <dt>Asset class</dt><dd>${esc(kf.assetClass)}</dd><dt>Fund size</dt><dd>${esc(kf.aum)}</dd>
      <dt>Inception</dt><dd>${esc(kf.inception)}</dd><dt>Distribution</dt><dd>${esc(kf.distribution)}</dd>
      <dt>Holdings</dt><dd>${esc(kf.holdings)}</dd><dt>Ongoing charges</dt><dd>${esc(d.headlineMetrics?.ter)}</dd></dl></div>
    <div class="panel"><h2>Top 10 holdings</h2><ol>${(d.topHoldings ?? []).slice(0, 10).map((h: any) => `<li>${esc(h.name)}<b>${h.weight.toFixed(2)}%</b></li>`).join("")}</ol></div>
    <div class="panel"><h2>Sector</h2>${bars(d.sector)}</div>
    <div class="panel"><h2>Geography</h2>${bars(d.geography)}</div>
    <div class="panel wide"><h2>Calendar returns (%)</h2><table><tr><th></th>${years.map((y) => `<th>${esc(y)}</th>`).join("")}</tr>
      <tr><td>Fund</td>${(d.calendarReturns?.fund ?? []).map((v: any) => `<td>${esc(v)}</td>`).join("")}</tr>
      <tr><td>Benchmark</td>${(d.calendarReturns?.benchmark ?? []).map((v: any) => `<td>${esc(v)}</td>`).join("")}</tr></table></div>
    <div class="panel"><h2>Risk indicator</h2><div class="scale">${[1, 2, 3, 4, 5, 6, 7].map((n) => `<span class="${n === risk ? "on" : ""}">${n}</span>`).join("")}</div>
      <small>Lower risk — Higher risk</small></div>
  </section>
  <footer><p>Past performance is not a reliable indicator of future results. Figures are taken from documents published by the fund's management company and reproduced without warranty. This document is information, not investment advice; read the KID and prospectus before investing. Generated ${esc(generatedAt)}.</p></footer>
  </body></html>`;
}
```

The escaping helper is not optional: fund names and objectives contain ampersands and the occasional angle bracket. Note that formatted strings such as `"0.20%"` and `"USD 151.7bn"` are printed as published, while weights (already in percent) are formatted by you. The [performance data explainer](https://fundfactsapi.com/blog/fund-performance-data-explained) describes how calendar and cumulative returns are laid out if you want to add a growth chart from `indexedPerformance.points` as an SVG polyline.

## Rendering and batching

Fetch each ISIN, render HTML, print to PDF. Reuse one browser for the whole batch; launching Chromium per fund is the slow part.

```typescript
// factsheet/batch.ts
import { chromium } from "playwright";
import { writeFile } from "node:fs/promises";
import { factsheetHtml } from "./template";

async function getFund(isin: string) {
  const r = await fetch(`https://fundfactsapi.com/api/v1/funds/${isin}`, {
    headers: { Authorization: `Bearer ${process.env.FUNDFACTS_API_KEY}` },
    signal: AbortSignal.timeout(300_000), // first load of a new ISIN can take 1–3 minutes
  });
  if (r.status === 404) return null;
  if (!r.ok) throw new Error(`${r.status} for ${isin}`);
  return r.json();
}

export async function batch(isins: string[], outDir = "out") {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  try {
    for (const isin of isins) {
      const fund = await getFund(isin);
      if (!fund) { console.warn(`${isin}: fund_not_found, skipped`); continue; }
      await page.setContent(factsheetHtml(fund), { waitUntil: "load" });
      const pdf = await page.pdf({ format: "A4", printBackground: true, preferCSSPageSize: true });
      await writeFile(`${outDir}/${isin}.pdf`, pdf);
      // The envelope carries the same numbers as the X-RateLimit-* headers.
      if (Number(fund.quota?.remaining ?? 1) === 0) { console.warn("quota exhausted, stopping"); break; }
    }
  } finally {
    await browser.close();
  }
}

batch(["IE00B4L5Y983", "IE00B3RBWM25", "LU1681043599", "FR0010135103"]);
```

Run funds sequentially. A cold ISIN takes one to three minutes the first time; a cached one returns in milliseconds, so a nightly batch over a stable list is quick after the first run. Each fund's JSON is refreshed every 24 hours and the envelope's `expiresAt` tells you when; if you generate more often than that you are printing the same numbers again. For a monthly client pack, running the batch once after month-end data appears in `dataAsOf` is the natural schedule. Use `quota.remaining` (or the `X-RateLimit-Remaining` header) to stop cleanly rather than hit a `429`.

## Production notes

- **Fonts.** Ship the brand font as a local file referenced from `brand.css`; do not rely on a web font loading inside a headless browser.
- **Page breaks.** Add `break-inside: avoid` to each panel. If a fund has an unusually long objective or many sectors, truncate rather than overflow.
- **Empty panels.** A bond fund has no sector table but does have `creditQuality` and `maturity`; an equity fund is the reverse. Render panels conditionally and fill the gap with the relevant one.
- **Versioning.** Store the JSON alongside the PDF. When a client questions a number, you want the exact payload that produced the page, with its `generatedAt`.
- **Where this goes.** Adviser portals, model-portfolio packs and periodic reporting are the usual homes; the [client reporting use case](https://fundfactsapi.com/use-cases/client-reporting) walks through the surrounding workflow.

Try it on the [free plan](https://fundfactsapi.com/signup): ten funds a day is enough to design the template against real payloads, and the field reference in the [documentation](https://fundfactsapi.com/docs) lists every key the template can use.

## About FundFacts API

FundFacts turns any fund or ETF ISIN into one dated factsheet (fees and KID costs, the 1–7 risk indicator, holdings, sector and country exposure, returns) read from the fund house's own documents: as a client-ready PDF under your firm's name for advisers and wealth managers, as JSON through its API for developers, or inside ChatGPT and Claude through its MCP server.

FundFacts works inside ChatGPT and Claude as a connector (a remote MCP server): ask about any fund or ETF by ISIN, ticker or name and the assistant answers from FundFacts' dated figures, fees, risk, holdings, exposure and returns, with fund cards, side-by-side comparisons, holdings overlap and portfolio look-through.

- Free API key, no card: https://fundfactsapi.com/signup
- Documentation: https://fundfactsapi.com/docs (OpenAPI: https://fundfactsapi.com/openapi.json)
- MCP server: https://fundfactsapi.com/api/mcp (set-up for every client: https://fundfactsapi.com/mcp-server)
- Pricing: https://fundfactsapi.com/pricing
- Site index for AI assistants: https://fundfactsapi.com/llms.txt
- Informational data, not investment advice.
