# 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.

Canonical: https://currency-core.com/docs/guides/dashboards/
Updated: 2026-11-12
Published: 2026-11-11

---
## The job

Show current rates and a short trend without calling the API on every page
render. Use [`/v1/rates`](/docs/rates-endpoint/) or
[`/v1/rates/{base}`](/docs/rates-endpoint/) for the snapshot, and
[`/v1/history`](/docs/history-endpoint/) 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:

```json
{
  "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.

```ts
// app/api/rates/route.ts

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

```tsx
"use client";

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 `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](/docs/guides/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

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

## Next steps

- [Rates endpoint](/docs/rates-endpoint/)
- [History endpoint](/docs/history-endpoint/)
- [Rate limits](/docs/rate-limits/)
- [Caching reference rates](/docs/guides/caching/)
- [Graceful degradation](/docs/guides/graceful-degradation/)
- [All developer guides](/docs/guides/)
