Integrations
Currency Dashboards
Build a rate dashboard with a server-side cache, a visible snapshot date, and a stale fallback instead of a request on every render.
The job
Show current rates and a short trend without calling the API on every page
render. Use /v1/rates or
/v1/rates/{base} for the snapshot, and
/v1/history for a sparkline or table of prior days.
Rates come from central-bank reference rates polled hourly. The underlying fixings are daily, so values normally change once per business day. Do not describe the feed as real-time, and do not expect websockets. There are none.
The data contract
A snapshot response contains:
{
"base": "USD",
"date": "2026-11-09",
"rates": {
"USD": 1,
"EUR": 0.9214,
"INR": 84.07
}
}
| Field | What it means for the UI |
|---|---|
base | The denominator. Every value is units of the currency per 1 base |
date | The snapshot date actually used |
requestedDate | Present only when the requested date had no snapshot |
asOf | Per-currency carried-forward dates, when present |
rates | The currency map |
If asOf exists, show the carried-forward date in a tooltip or footnote. A
weekend snapshot can contain Friday’s values, and individual currencies can carry
an older value.
A server route with a cache
Keep the API key on the server. This Next.js route caches by base for one hour, deduplicates concurrent refreshes, and serves the last good snapshot if a refresh fails.
// app/api/rates/route.ts
import { NextResponse } from "next/server";
type Snapshot = {
base: string;
date: string;
requestedDate?: string;
asOf?: Record<string, string>;
rates: Record<string, number>;
};
type CacheEntry = { body: Snapshot; fetchedAt: number };
const TTL_MS = 60 * 60 * 1000;
const cache = new Map<string, CacheEntry>();
const inflight = new Map<string, Promise<Snapshot>>();
async function fetchSnapshot(base: string): Promise<Snapshot> {
const res = await fetch(`https://api.currency-core.com/v1/rates/${base}`, {
headers: { Authorization: `Bearer ${process.env.CURRENCECORE_API_KEY}` },
signal: AbortSignal.timeout(8000),
});
const body = await res.json();
if (!res.ok) {
throw Object.assign(new Error(body.error?.message ?? `HTTP ${res.status}`), {
status: res.status,
code: body.error?.code,
});
}
return body as Snapshot;
}
export async function GET(request: Request) {
const base = (new URL(request.url).searchParams.get("base") ?? "USD").toUpperCase();
const now = Date.now();
const entry = cache.get(base);
if (entry && now - entry.fetchedAt < TTL_MS) {
return NextResponse.json({ ...entry.body, stale: false, ageMs: 0 });
}
if (!inflight.has(base)) {
inflight.set(
base,
fetchSnapshot(base)
.then((body) => {
cache.set(base, { body, fetchedAt: Date.now() });
return body;
})
.finally(() => inflight.delete(base)),
);
}
try {
const body = await inflight.get(base)!;
return NextResponse.json({ ...body, stale: false, ageMs: 0 });
} catch {
if (entry) {
return NextResponse.json({
...entry.body,
stale: true,
ageMs: now - entry.fetchedAt,
});
}
return NextResponse.json({ error: "rates_unavailable" }, { status: 503 });
}
}
This is a request-triggered cache, not a background job. A scheduled refresh is also fine, but keep the same rule: serve the last good snapshot with its date attached.
A small client component
"use client";
import { useEffect, useState } from "react";
type Snapshot = {
base: string;
date: string;
stale?: boolean;
ageMs?: number;
asOf?: Record<string, string>;
rates: Record<string, number>;
};
export function RateTicker({ base = "USD" }: { base?: string }) {
const [data, setData] = useState<Snapshot | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
async function load() {
const res = await fetch(`/api/rates?base=${base}`);
if (!res.ok) {
if (!cancelled) setFailed(true);
return;
}
const body = (await res.json()) as Snapshot;
if (!cancelled) {
setData(body);
setFailed(false);
}
}
load();
const timer = setInterval(load, 5 * 60 * 1000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [base]);
if (failed && !data) return <span>Rates unavailable</span>;
if (!data) return <span>Loading rates...</span>;
return (
<div>
<strong>{data.rates.EUR?.toFixed(4)}</strong>
<span> EUR per 1 {data.base}</span>
<small>
{data.stale ? "Stale snapshot" : "Snapshot"} {data.date}
</small>
</div>
);
}
The component does not know the API key and never calls the API directly. It only calls your route.
Refresh cadence
| Surface | Cadence | Why |
|---|---|---|
| Header ticker | Cache for minutes, refresh on a timer | It is a display surface, not a quote |
| Daily table | Once per business day | The underlying fixings are daily |
| Historical chart | Fetch once per range | The past does not change |
| Checkout | Fix at the order, not the dashboard cadence | The charge must be stable |
A dashboard that refreshes every few minutes is usually enough. A business-day cadence is often better and cheaper. Watch the per-minute limit, which counts every request including retries.
Outage behavior
- Display can use a last-known snapshot with a visible date.
- Checkout should fail closed beyond a tight stale age. Pricing an order from an ancient rate is worse than rejecting it.
- Treat
429as a pacing signal, not an outage. HonorRetry-After. - Treat
5xx, timeouts and connection failures as service failures. - Do not show a stale value without a date or a warning.
See graceful degradation for a circuit breaker and a maximum stale age per use.
Worked example
A dashboard with three surfaces:
- Header ticker. One snapshot per base, cached for 15 minutes. Shows
EUR per 1 USDand the snapshot date. - Daily table. One snapshot per business day, cached until the next
business day. Shows
asOffor any carried-forward currency. - 30-day chart. One
/v1/historycall for the range, cached for the day. Shows the date map as a line and marks repeated values.
Three cache keys, three fetch cadences, one API key on the server. A page reload does not create three API calls.
Failure handling
| Status | Dashboard behavior |
|---|---|
400 invalid_input | Fix the base code; do not render an empty chart as zero |
401 unauthorized | Check the server key |
403 forbidden | A Free key asked for a date outside the 30-day window |
429 rate_limited | Honor Retry-After; serve the cached snapshot with its date |
5xx or timeout | Serve the last good snapshot if it is inside your stale-age policy |
| No snapshot at all | Show an unavailable state, not a guessed rate |