Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, senior full-stack software engineer. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

[email protected]
  1. Home
  2. /
  3. Blog
  4. /
  5. Caching VAT and FX Rates at Build Time, Not on the Request Path

Iurii Caches: Caching VAT and FX Rates at Build Time, Not on the Request Path

Live rates on every request add latency and a new failure mode. Hardcoded rates go stale silently. A prebuild fetch with a hard fallback avoids both.

September 11, 2026· 9 min read

Why a Next.js e-commerce project fetches EUR/VAT reference data once per build instead of once per request or never – the prebuild script, the ‘never fails the build’ fallback, and where this pattern stops being honest.

Stack

Node.jsTypeScript

Services

ECBnpm

Topics

Build ToolsAutomationCachingTax/VATArchitecture
Caching VAT and FX Rates at Build Time, Not on the Request Path

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
Iurii RoguliaAvailable

Automation & Workflows

Have exchange rates, tax tables, or any other slow-moving external data sitting in a request path that doesn’t need it there? Moving it to build time is exactly the kind of automation work I do.

More about this service

Relevant client work

View all projects
n8n Verified Community Node – EU VAT Validation in Workflows
n8n Verified Community Node – EU VAT Validation in Workflows
August 6, 2026
n8n Verified Community Node – EU VAT Validation in Workflows

Verified n8n node that puts VIES VAT validation and EU rate lookups inside a workflow, so onboarding or invoicing can decide on a real VAT number instead of a

eu-vat-rates-data – Free & Open-Source EU VAT Rates Dataset
eu-vat-rates-data – Free & Open-Source EU VAT Rates Dataset
February 25, 2026
eu-vat-rates-data – Free & Open-Source EU VAT Rates Dataset

Free, open-source EU VAT rates for all 27 member states + UK. Published as native packages for npm, PyPI, Packagist, Go, and RubyGems.

vatnode.dev mcp – Official MCP Server for EU VAT Validation
vatnode.dev mcp – Official MCP Server for EU VAT Validation
May 20, 2026
vatnode.dev mcp – Official MCP Server for EU VAT Validation

Open-source MCP server that lets Claude Desktop, Cursor and other MCP clients validate EU VAT numbers and look up rates directly in chat.

What clients say

“

I’d built most of our MVP with Cursor and it looked finished – it compiled, the tests were green, the demo worked. It just wouldn’t survive real users.

Sebastian Falk 🇸🇪

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
“

I was starting a new project and spent two weeks reading comparisons online and getting nowhere. One hour with Iurii and I had a clear answer.

Marcus Chen 🇺🇸

Stack

TypeScript

Topics

Architecture
“

Iurii set up a second Telegram bot just for our side of the business. Once a month it sends me every invoice and accounting document I need without anyone having to dig through folders, and it

Tatiana 🇪🇪

Accountant, pi-pi.ee

Services

Telegram

Topics

Telegram BotAutomationAIDocument Automation

Related articles

BullMQ vs pg-boss vs Cron: Node.js Background Jobs Compared
May 18, 2026· 15 min
BullMQ vs pg-boss vs Cron: Node.js Background Jobs Compared

BullMQ vs pg-boss vs node-cron for Node.js background jobs. Trade-offs between Redis and Postgres queues, retries, deduplication, and production monitoring.

Stack

Node.jsTypeScript

Libraries

BullMQpg-boss

Databases

PostgreSQLRedis

Topics

ArchitectureSaaSAutomation
IndexNow in Next.js: Instant Indexing After Every Deploy
April 14, 2026· 18 min
IndexNow in Next.js: Instant Indexing After Every Deploy

IndexNow implementation guide for Next.js: key generation, TypeScript client with retry logic, GitHub Actions workflow, and pitfalls that break submissions

Stack

TypeScriptNode.js

Services

GitHub Actions

Topics

SEOAutomationPerformanceArchitecture
Publishing One Package to Five Registries with GitHub Actions
March 27, 2026· 10 min
Publishing One Package to Five Registries with GitHub Actions

How to publish one dataset to npm, PyPI, Go Module, RubyGems, and Packagist automatically with GitHub Actions – architecture, versioning, and per-ecosystem

Stack

TypeScriptPythonPHPGoRuby

Services

GitHub ActionsnpmPyPIPackagistRubyGems

Topics

Open SourceTax/VATAutomation
Booking a Shipment from an Order Pipeline Without Stalling the Rest of the Order
September 9, 2026· 9 min
Booking a Shipment from an Order Pipeline Without Stalling the Rest of the Order

How to call a logistics provider’s API from an order-processing worker: why some booking APIs answer immediately and others don’t, whether a shipping outage

Stack

TypeScriptNode.js

Libraries

BullMQ

Databases

PostgreSQL

Services

PostNord

Topics

WebhooksAPIIdempotencyArchitectureLogistics
Wiring an Accounting System into a Payment Webhook Without Losing Money
September 4, 2026· 11 min
Wiring an Accounting System into a Payment Webhook Without Losing Money

How to wire an external accounting or bookkeeping API into a payment flow: why the call belongs in the queued worker rather than the webhook handler, how to

Stack

TypeScriptNode.js

Databases

PostgreSQL

Services

StripeNetvisor

Topics

WebhooksAPIAccountingIdempotencyArchitecture