Two ways to get exchange rates or VAT tables into an application, and both are wrong in a familiar way. Call a rates API on every request or every checkout, and you’ve added latency plus a new third-party dependency to a path where a customer is trying to pay you money. Or hardcode a { GBP: 0.86, CHF: 0.94 } object in source once, and it drifts from reality the moment the ECB updates its reference rates – silently, because nothing breaks when a number is merely wrong.
A third option avoids both failure modes. It’s the one I used on a B2B e-commerce project I built – a Next.js storefront selling across 32 European markets (the project card covers the broader VAT and payments architecture). Fetch the rates once, at build time, cache them to a committed JSON file, and let the running application read that file instead of the network. Here’s the script and the reasoning behind each of its decisions.
The Prebuild Script
The relevant file is scripts/fetch-fx-rates.js, a prebuild step that runs before next build and nowhere else:
// scripts/fetch-fx-rates.js
const fs = require("fs");
const path = require("path");
const OUTPUT_FILE = path.join(__dirname, "../src/data/fx-rates.json");
const TARGET = ["GBP", "CHF", "NOK", "PLN", "CZK", "DKK", "SEK", "RON", "HUF", "TRY"];
async function fetchRates() {
const url = `https://api.frankfurter.app/latest?from=EUR&to=${TARGET.join(",")}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const rates = {};
for (const cur of TARGET) {
const r = data.rates[cur];
if (typeof r !== "number") throw new Error(`missing rate for ${cur}`);
rates[cur] = r;
}
const output = {
generatedAt: new Date().toISOString(),
base: "EUR",
source: "ECB via frankfurter.app",
note: "Approximate reference only. Checkout charges in EUR.",
rates,
};
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2) + "\n");
}
fetchRates().catch((err) => {
console.error("❌ FX fetch failed:", err.message);
console.error(" Keeping the committed fx-rates.json as-is.");
process.exit(0); // Don't fail the build
});frankfurter.app mirrors the European Central Bank’s daily reference rates, needs no API key, and returns exactly the ten non-euro currencies this storefront ships to. The script fetches EUR-to-X for all ten in one request and writes the result to src/data/fx-rates.json, which is committed to the repo like any other source file.
That JSON feeds one thing: a small, clearly labeled UI hint next to a EUR price for a non-euro shopper – ‘≈ £25’ next to ‘€29’. It is never the transactional amount. The checkout itself still charges in EUR regardless of which country the buyer is in; the FX number is a courtesy conversion so a UK buyer has some sense of what €29 means without doing the math themselves. That sets how fresh this data actually needs to be.
‘Never Fails the Build’ Is the Load-Bearing Line
Read the .catch() block again. On any failure – frankfurter.app is down, rate-limited, returns a malformed payload, or the network blips in CI – the script logs the error, leaves the already-committed fx-rates.json untouched, and calls process.exit(0). Exit code zero. As far as the shell and the CI pipeline are concerned, the prebuild step succeeded.
The alternative is to throw and let a failed fetch fail the whole build. That sounds more correct – if the data is stale, don’t ship – until you look at what actually triggers this build. Every deploy does: a copy fix, a new blog post if content lives in the same repo, an unrelated bug fix in checkout logic that has nothing to do with currency conversion. Tie all of that to the uptime of a third-party FX API, and a transient 503 from frankfurter.app on a Tuesday afternoon blocks a hotfix that has nothing to do with FX rates.
Shipping a build with FX numbers a day, or a week, old is the cheaper failure. The fallback here isn’t ‘no data,’ it’s yesterday’s real data instead of today’s real data. The committed JSON was itself fetched from the same source at some previous build, so it degrades to slightly stale rather than to zero. A hint that’s approximate by design tolerates that easily. A deploy pipeline tolerates a new external dependency on its critical path much worse.
Same shape of decision as retrying a webhook call versus failing it outright. The useful question is what failure actually costs here, and whether blocking the whole pipeline is proportionate to that cost. For a display-only currency hint, it isn’t.
Dev Mode Never Touches the Network for This
The prebuild hook only runs on npm run build. npm run dev never invokes fetch-fx-rates.js at all – it just reads whatever fx-rates.json is already sitting in the repo, committed with sane values. The omission is deliberate. A local dev session doesn’t need today’s GBP rate to be accurate to the fourth decimal place to build a checkout page. It needs a plausible number so the UI renders correctly, and it needs that without a network call that can fail, rate-limit, or simply add a few hundred milliseconds to every cold start.
The stronger reason is one most caching write-ups skip: a build script that hits an external API gives local development a network dependency that has nothing to do with the feature you’re working on. Committing the cache file removes that dependency entirely for the vast majority of work sessions that aren’t specifically about the FX integration.
VAT Rates: Same Instinct, Different Failure Direction
The VAT side of the same project makes the identical build-time trade – but pairs it with the opposite default on unknown input. VAT rates come from getAllRates() in eu-vat-rates-data, a package I publish that mirrors the European Commission’s VAT data (the pipeline that keeps it current is its own story, covered in Publishing One Package to Five Registries with GitHub Actions). No live call to a tax authority on checkout – the rate for every EU member is bundled into the deployed app at build time, versioned through the package’s dataVersion:
// src/lib/pricing/config.ts
const packageRates = getAllRates();
export const VAT_RATES: Record<string, VatRateInfo> = {
...Object.fromEntries(
Object.entries(packageRates).map(([code, data]) => [
code,
{ name: data.country, rate: data.eu_member ? data.standard / 100 : 0 },
])
),
US: { name: "United States", rate: 0 },
};
export const RATES_UPDATED_AT = dataVersion;
export function getVatRate(countryCode: string): number {
const rateInfo = VAT_RATES[countryCode];
if (rateInfo !== undefined) return rateInfo.rate;
if (countryCode in ZERO_VAT_TERRITORIES) return 0;
// Fail fast on a fully unknown code rather than silently charging 0% VAT.
throw new Error(`getVatRate: unknown country code "${countryCode}"`);
}Here the design intentionally does not fall back the way the FX script does. getVatRate throws on a country code that isn’t in the bundled table and isn’t in an explicit, named ZERO_VAT_TERRITORIES exception list (Åland, a served country that’s simply absent from the VAT-rates package’s data). That asymmetry is the point, not an inconsistency: an approximate FX hint failing over to ‘yesterday’s number’ costs nothing but precision. A VAT calculation silently failing over to 0% costs money – either undercharged tax that the seller owes, or, worse, a shipped default that nobody notices until a filing is wrong. Same build-time caching on both sides of this codebase, opposite failure behavior, because the two data points have opposite blast radii when they’re wrong. This checkout flow’s actual VAT and currency math at request time – how the cached numbers get applied and re-verified server-side – is a separate story from the caching mechanism itself.
Where a Build-Old Number Is Fine, and Where It Isn’t
The cost is staleness. The rates in fx-rates.json are as fresh as the last successful npm run build, not as fresh as the last minute. For this project that’s a non-issue twice over: the FX number is explicitly a display-only hint, never the charged amount, and standard VAT rates change on the order of once a year through a legislated, announced process, not intraday.
In a different context it stops being fine. A system that actually settles in the converted currency – an FX trading desk, a multi-currency ledger reconciling against real bank movements – cannot tolerate a build-old rate. That’s a request-time, ideally streaming, problem, and treating it as a build artifact would be a genuine bug rather than a trade-off. VAT has its own version of this: a country raises or lowers its standard rate on a known effective date, the last build ran the evening before, and the deploy lands after midnight. Then ‘rebuild before the rate changes’ becomes an actual operational task, and this pattern can’t paper over it. This project doesn’t have that problem, because the number it caches is a hint rather than a settlement and the tax table it caches changes on a calendar that a normal deploy cadence easily outpaces. That’s a property of this specific system, not a property of build-time caching in general.
The Alternative Nobody Admits to Shipping
The pattern this replaces is usually not ‘call the API live.’ More often it’s a const rates = { GBP: 0.86, CHF: 0.94, ... } committed once during initial development and never touched again, because nothing forces anyone to touch it. No build step fails, no test catches it, no dashboard flags it – the numbers are just quietly wrong, more wrong every month, until a customer complains that the displayed conversion looks off, or a VAT filing gets flagged. A committed JSON that a script refreshes on every build looks identical to a hardcoded object at rest – same file, same shape, no runtime cost – but it doesn’t rot, because refreshing it is a side effect of the thing you were going to do anyway: ship.
The same move – take a slow-changing computation out of the request path and into the build, so the running app never pays for it and nobody has to remember to redo it by hand – shows up elsewhere on this stack, for a completely different kind of data. This site’s own related-posts graph is computed by a generate-relations.ts script that runs as a codegen step in the same next build chain, before the framework build starts. Different problem – internal content relationships, not external API data – but the same reasoning: if a value doesn’t need to be correct to the second, compute it once when you’re already deploying, not on every page load.
If you’ve got a rates table, a tax lookup, or any other slow-moving external data sitting in your request path out of habit rather than necessity, get in touch and let’s find out whether it belongs at build time instead. That’s the kind of workflow automation covered on the Automation & Workflows page.
Further reading:
- Publishing One Package to Five Registries with GitHub Actions – how
eu-vat-rates-data, the package behind the VAT table in this article, stays current with zero manual steps - A Typed MDX Content Pipeline with Velite (Next.js Tutorial) – this site’s own build-time codegen step, a different kind of ‘compute it once, not per request’
- pi-pi.ee – B2B E-commerce for Waterless Urinal Systems – the project this VAT and FX caching pattern is drawn from









