TutorialReportingPDF

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.Published 9 September 2026 · 5 min read · by FundFacts API

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: it starts from the structured JSON FundFacts API 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 effortLow: CSS grid, flexbox, fonts you already useHigh: coordinates, manual wrapping
ChartsAny HTML/SVG chart, or plain CSS barsDraw rectangles yourself or embed images
Fidelity to your web brandExact, same stylesheetReimplemented
Runtime footprintA browser binary (~300 MB), slower cold startSmall, fast, runs anywhere
Deterministic outputMostly; font loading and page breaks need careFully
Best forBatches on a server or CI job, rich layoutsServerless, 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.

PanelFields
Headername, isin, data.keyFacts.currency, data.profile.category, data.dataAsOf
Key factskeyFacts.assetClass, keyFacts.aum, keyFacts.inception, keyFacts.distribution, keyFacts.holdings, keyFacts.manager, benchmarkName
ObjectiveinvestmentObjective (trim to three or four lines)
Top 10 holdingstopHoldings[0..9] as name and weight
Sector and geographysector and geography as horizontal bars
Calendar returnscalendarReturns.years with .fund and .benchmark
Risk indicatorriskRating on a 1–7 scale
FeesheadlineMetrics.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 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) 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 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 walks through the surrounding workflow.

Try it on the free plan: ten funds a day is enough to design the template against real payloads, and the field reference in the documentation lists every key the template can use.

Try it on your own ISINs

One request returns key facts, holdings, risk and performance as JSON. Free plan, no card.