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
  }
}
FieldWhat it means for the UI
baseThe denominator. Every value is units of the currency per 1 base
dateThe snapshot date actually used
requestedDatePresent only when the requested date had no snapshot
asOfPer-currency carried-forward dates, when present
ratesThe 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

SurfaceCadenceWhy
Header tickerCache for minutes, refresh on a timerIt is a display surface, not a quote
Daily tableOnce per business dayThe underlying fixings are daily
Historical chartFetch once per rangeThe past does not change
CheckoutFix at the order, not the dashboard cadenceThe 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 429 as a pacing signal, not an outage. Honor Retry-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:

  1. Header ticker. One snapshot per base, cached for 15 minutes. Shows EUR per 1 USD and the snapshot date.
  2. Daily table. One snapshot per business day, cached until the next business day. Shows asOf for any carried-forward currency.
  3. 30-day chart. One /v1/history call 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

StatusDashboard behavior
400 invalid_inputFix the base code; do not render an empty chart as zero
401 unauthorizedCheck the server key
403 forbiddenA Free key asked for a date outside the 30-day window
429 rate_limitedHonor Retry-After; serve the cached snapshot with its date
5xx or timeoutServe the last good snapshot if it is inside your stale-age policy
No snapshot at allShow an unavailable state, not a guessed rate

Next steps