TutorialArchitectureNext.js

Build a fund comparison tool in an afternoon with a fund data API

A practical architecture for a fund screener or comparison page: ISIN input, caching, side-by-side table, charts and a PDF export, with code in Next.js and Python.Published 29 July 2026 · 2 min read · by FundFacts API

A side-by-side fund comparison is the feature clients ask for most and the one that used to need a data licence. With a fund data API that returns one schema for every ISIN, it fits in an afternoon. This post lays out the architecture, the pitfalls and the code.

What we are building

A page where a user pastes two to five ISINs and gets:

  1. A comparison table: fund size, TER, inception, risk indicator, 1/3/5-year returns, volatility, Sharpe.
  2. A growth-of-100 chart with all funds on one axis.
  3. Exposure panels: sector and geography donuts per fund, plus a holdings-overlap score.
  4. A PDF/print view for advisors.

Everything comes from GET /api/v1/funds/{isin} on FundFacts API, so the backend is a thin proxy.

Architecture

text
Browser ──► /api/compare?isins=A,B,C ──► your server ──► FundFacts API (per ISIN, parallel)
└─ your cache (Redis / DB) keyed by ISIN, TTL 24h

Two rules:

  • Never call the fund API from the browser. Your key would leak and every visitor would burn your quota. Proxy it.
  • Cache on your side too. The API caches for 24 hours, but each call still counts against your plan. Storing the response next to expiresAt means a popular comparison costs you nothing after the first view.

The server route (Next.js)

typescript
// app/api/compare/route.ts
import { NextResponse } from "next/server";
import { cache } from "@/lib/cache"; // any KV with get/set(ttl)
const BASE = "https://fundfactsapi.com/api/v1/funds";
async function getFund(isin: string) {
const hit = await cache.get(isin);
if (hit) return hit;
const res = await fetch(`${BASE}/${isin}`, {
headers: { Authorization: `Bearer ${process.env.FUNDFACTS_API_KEY}` },
signal: AbortSignal.timeout(300_000),
});
if (!res.ok) throw new Error(`${isin}: ${res.status}`);
const json = await res.json();
const ttl = Math.max(60, (new Date(json.expiresAt).getTime() - Date.now()) / 1000);
await cache.set(isin, json, ttl);
return json;
}
export async function GET(req: Request) {
const isins = new URL(req.url).searchParams.get("isins")?.split(",").slice(0, 5) ?? [];
const results = await Promise.allSettled(isins.map(getFund));
return NextResponse.json(
results.map((r, i) => (r.status === "fulfilled" ? r.value : { isin: isins[i], error: String(r.reason) })),
);
}

Promise.allSettled matters: one unknown ISIN should not break the whole comparison.

Shaping the comparison table

Pick the fields once, then render generically:

typescript
const pct = (s?: string | null) => (s ? Number(/-?\d+(\.\d+)?/.exec(s)?.[0]) : null);
export const COLUMNS = [
{ label: "Fund size", get: (f) => f.data.keyFacts.aum },
{ label: "TER", get: (f) => f.data.headlineMetrics.ter, lowerIsBetter: true },
{ label: "Inception", get: (f) => f.data.keyFacts.inception },
{ label: "Risk (1–7)", get: (f) => f.data.riskRating },
{ label: "1Y", get: (f) => f.data.annualisedReturns.find((r) => r.label === "1 Year")?.fund },
{ label: "5Y p.a.", get: (f) => f.data.annualisedReturns.find((r) => r.label.startsWith("5 Years"))?.fund },
{ label: "Volatility 3y", get: (f) => f.data.headlineMetrics.volatility3y, lowerIsBetter: true },
{ label: "Sharpe 3y", get: (f) => f.data.headlineMetrics.sharpe3y },
{ label: "Holdings", get: (f) => f.data.keyFacts.holdings },
];

Highlight the best value per row (lowerIsBetter for fees and volatility) — it is the single most useful visual cue in a comparison table.

One chart, many funds

Each fund's indexedPerformance.points starts at 100 at its own inception. To overlay funds with different launch dates, rebase all series at the latest common start date:

typescript
export function rebase(series: { date: string; fund: number }[][], from: string) {
return series.map((s) => {
const start = s.find((p) => p.date >= from)!.fund;
return s.filter((p) => p.date >= from).map((p) => ({ date: p.date, value: (p.fund / start) * 100 }));
});
}
const commonStart = series.map((s) => s[0].date).sort().at(-1)!;

Then render one line per fund. The growth chart on our landing page is a 60-line SVG component you can copy.

Exposure and overlap

Use data.sector and data.geography for donuts, and compute overlap between topHoldings arrays as described in the ETF holdings guide. Showing "these two ETFs share 38 % of their top-10 weight" is often the insight that changes the decision.

PDF export

Server-render the comparison page and print it with the browser's print CSS, or use a headless browser to produce a PDF. Because every chart is inline SVG, there is nothing to rasterise. Put generatedAt and data.dataAsOf in the footer — compliance will ask.

Python variant

If your stack is Python, the same proxy is a dozen lines with requests and functools.lru_cache or Redis; the JSON shape is identical. See the ISIN API tutorial for the client code.

Costs

A comparison of five funds costs at most five requests the first time and zero afterwards for 24 hours. On the Pro plan (100 requests / day) that is twenty fresh comparisons a day; on Scale (1,000 / day) it is a screener over a few hundred funds refreshed nightly. Start on the free plan to build it, upgrade when you ship.

Try it on your own ISINs

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