Code Examples

React with a Backend Proxy

Call CurrencyCore through your own backend so the API key never enters the browser bundle, with loading, error, and abort handling.


React runs in the browser, and that is the whole problem. A direct call to https://api.currency-core.com/v1 is cross-origin. The Authorization header makes it a non-simple request, so the browser sends a preflight first. CurrencyCore’s CORS policy allows its own origins, not arbitrary customer sites, so the preflight or the response is blocked. Even if it were allowed, the key would be delivered to every visitor.

Vite environment variables do not solve this. Anything named VITE_* is replaced at build time and ends up in the JavaScript bundle. Opening DevTools reveals it. A variable is not secret because it lives in .env; it is secret only if it stays on a server.

The fix is a route on your own origin. The browser calls /api/rates, your server adds the key, and the upstream response comes back same-origin. Here is a small proxy:

npm install express @currencycore/sdk
// server/proxy.mjs
import express from "express";
import { createClient, ResponseError } from "@currencycore/sdk";

const app = express();
const api = createClient(); // reads CURRENCYCORE_API_KEY

app.get("/api/rates", async (req, res) => {
  const base = String(req.query.base ?? "USD").toUpperCase();
  const date = String(req.query.date ?? "");
  if (!/^[A-Z]{3}$/.test(base) || (date && !/^\d{4}-\d{2}-\d{2}$/.test(date))) {
    return res.status(400).json({
      error: { code: "invalid_input", message: "Invalid base or date" },
    });
  }

  try {
    const body = await api.ratesByBase({
      base,
      ...(date ? { date } : {}),
    });
    res.json(body);
  } catch (error) {
    if (error instanceof ResponseError) {
      const body = await error.response.json().catch(() => null);
      const retryAfter = error.response.headers.get("retry-after");
      if (retryAfter) res.set("Retry-After", retryAfter);
      return res.status(error.response.status).json(
        body ?? { error: { code: "http_error", message: "Upstream request failed" } },
      );
    }
    res.status(502).json({
      error: { code: "internal_error", message: "Upstream request failed" },
    });
  }
});

app.listen(8787);

During local development, point Vite at that server so the component’s relative URL works:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: { proxy: { "/api": "http://localhost:8787" } },
});

In production, serve /api/rates from the same host as the built assets, or put both behind one reverse proxy.

import { useEffect, useState } from "react";

type Snapshot = { base: string; date: string; rates: Record<string, number> };

export function RateTable({ base = "EUR" }: { base?: string }) {
  const [data, setData] = useState<Snapshot | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(`/api/rates?base=${encodeURIComponent(base)}`, {
      signal: controller.signal,
    })
      .then(async (res) => {
        const body = await res.json();
        if (!res.ok) throw new Error(body?.error?.message ?? `HTTP ${res.status}`);
        return body as Snapshot;
      })
      .then(setData)
      .catch((err: unknown) => {
        if (err instanceof Error && err.name !== "AbortError") {
          setError(err.message);
        }
      })
      .finally(() => {
        if (!controller.signal.aborted) setLoading(false);
      });

    return () => controller.abort();
  }, [base]);

  if (loading) return <p>Loading rates...</p>;
  if (error) return <p role="alert">{error}</p>;
  if (!data) return null;

  return (
    <ul>
      <li>{data.base} = 1 ({data.date})</li>
      <li>USD {data.rates.USD}</li>
      <li>INR {data.rates.INR}</li>
    </ul>
  );
}
{
  "base": "EUR",
  "date": "2026-11-10",
  "rates": { "EUR": 1, "USD": 1.0861, "INR": 91.62 }
}

The abort cleanup matters beyond tidiness: when base changes quickly, the old response can resolve after the new one and overwrite it. Passing the signal cancels the request, and the aborted check prevents a stale loading update.

Common mistakes

  • Calling the API from the component and adding mode: "no-cors". That returns an opaque response you cannot read.
  • Moving the key to VITE_CURRENCYCORE_API_KEY. The prefix changes nothing about secrecy.
  • Exposing an unvalidated proxy. Restrict base and date, and add authentication or rate limiting before production.
  • Caching a latest response forever. Only dated snapshots can use a long TTL.

Next steps