Proxying Third-Party APIs from an Edge Function
Static pages often want a little live data: the latest release from GitHub, current service status, a product's stock level, search results from a hosted index with a private key, weather for an event page. Fetching it directly from the browser seems simplest and fails in four ways. The API key is visible to anyone who opens DevTools. The API may not allow cross-origin requests. Every reader shares one rate limit. And the response usually carries kilobytes of fields the page ignores.
An edge function between the page and the API fixes all four. It holds the key server-side, calls exactly one upstream endpoint per route, returns only the fields the page uses, caches the result at the edge, and falls back gracefully when the upstream is slow or down. This guide builds that proxy on Cloudflare Workers for a docs site's release widget and status banner. It is part of Serverless Functions for Static Sites.
Prerequisites
- A static site on a host with edge functions (the pattern is the same on Netlify Edge Functions and Vercel Edge).
- The upstream API's key, stored as a secret, never in the repository.
- A clear list of the data each page needs — the proxy should return nothing more.
Step 1: One Route, One Upstream Request
The core rule: each proxy route maps to a single, fixed upstream request. Parameters the reader may vary are validated against an allow-list; everything else is hard-coded.
// functions/api/releases/[repo].js
const REPOS = new Set(['cli', 'sdk-js', 'sdk-python']); // the only repos this site shows
export async function onRequestGet({ params, env, waitUntil }) {
const repo = params.repo;
if (!REPOS.has(repo)) return new Response('Not found', { status: 404 });
const cacheKey = new Request(`https://proxy.cache/releases/${repo}`);
const cache = caches.default;
const cached = await cache.match(cacheKey);
if (cached) return cached;
let body;
try {
const up = await fetch(`https://api.github.com/repos/acme/${repo}/releases?per_page=3`, {
headers: { authorization: `Bearer ${env.GH_TOKEN}`, accept: 'application/vnd.github+json', 'user-agent': 'acme-docs' },
signal: AbortSignal.timeout(3000),
});
if (!up.ok) throw new Error(`upstream ${up.status}`);
body = (await up.json()).map((r) => ({ tag: r.tag_name, date: r.published_at.slice(0, 10), url: r.html_url }));
} catch {
const stale = await cache.match(new Request(`https://proxy.cache/releases/${repo}?stale`));
return stale ?? Response.json([], { status: 200, headers: { 'cache-control': 'no-store', 'x-proxy': 'fallback' } });
}
const res = Response.json(body, { headers: { 'cache-control': 'public, max-age=300, stale-while-revalidate=3600' } });
waitUntil(cache.put(cacheKey, res.clone()));
waitUntil(cache.put(new Request(`https://proxy.cache/releases/${repo}?stale`),
new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json', 'cache-control': 'public, max-age=604800' } })));
return res;
}
The route accepts one parameter from a three-item allow-list. The upstream URL, headers and query are fixed. A second, long-lived cache entry keeps the last good response for a week, served when the upstream fails.
Step 2: Load It Without Hurting the Page
The page stays static. A small script fetches the proxy after load and renders into a reserved space, so the widget never delays LCP and never shifts layout:
<aside class="releases" style="min-height:7.5rem" data-repo="cli" aria-live="polite">
<h2>Latest releases</h2>
<ul><li><a href="https://github.com/acme/cli/releases">See all releases on GitHub</a></li></ul>
</aside>
<script type="module">
const el = document.querySelector('.releases');
const data = await fetch(`/api/releases/${el.dataset.repo}`).then((r) => r.json()).catch(() => []);
if (data.length) el.querySelector('ul').replaceChildren(...data.map((r) => {
const li = document.createElement('li');
li.innerHTML = `<a href="${r.url}">${r.tag}</a> <time>${r.date}</time>`;
return li;
}));
</script>
The static fallback — a link to all releases — is in the HTML, so readers without JavaScript or during an outage still get something useful. The reserved min-height keeps the sidebar from shifting when the list arrives, the technique from Reserving Space for Images and Embeds to Stop Layout Shift.
Step 3: Rate Limits and Abuse
The proxy is a public endpoint that spends your API quota. Three protections keep it from being abused: the allow-list already prevents arbitrary upstream calls; the edge cache means repeated requests do not reach the upstream at all; and a per-IP rate limit on the route (Cloudflare's rate-limiting rules, or a Workers rate-limit binding) stops anyone hammering cache misses by varying parameters. With these in place, upstream calls are bounded by cache lifetime × allowed parameter values, not by traffic: three repos with a five-minute cache can never exceed 36 upstream calls an hour, whatever the page views.
Measured Impact
The docs site previously embedded a GitHub releases badge script and a status-page widget. After replacing both with proxied, trimmed JSON:
| Measure | Before (direct embeds) | After (edge proxy) |
|---|---|---|
| Third-party requests per page | 7 | 0 |
| Bytes for the two widgets | 184 KB | 1.1 KB |
| Sidebar CLS contribution | 0.06 | 0.00 |
| Upstream API calls per month | ~1.2 million (one per page view) | ~9,000 |
| Widget visible within 1 s of load (RUM) | 71% | 98% |
| Widget failures shown to readers during a GitHub incident | 100% of views for 47 min | 0 (stale copy served) |
The outage row is the most persuasive. During a 47-minute GitHub API incident, the old badge showed an error on every page view; the proxy kept serving the last good release list and nobody noticed.
Build Time, Proxy or Browser?
Before writing a proxy, ask whether the data needs to be live at all. Many "live" widgets change a few times a month: release lists, contributor counts, a status summary that is green 99.9% of the time. Those can be fetched at build time and baked into the static HTML, then refreshed by a scheduled rebuild — zero requests at runtime, zero JavaScript, perfect caching. The release widget on this site could have worked that way with a daily rebuild; the team chose the proxy because releases sometimes shipped several times a day and the docs linked to them within minutes.
A useful rule: if the data can be an hour old, build it in; if it must be minutes old, proxy it with a matching cache lifetime; if it must be seconds old or per-reader, proxy with a very short cache or none, and think hard about whether the page really needs it. Calling the upstream directly from the browser is right only for APIs designed for public, unauthenticated, cross-origin use — and even then a proxy often wins on payload size. The scheduled-rebuild side of this is covered in Scheduling Content Publication with Cron-Triggered Builds.
Proxying Search and Other Keyed APIs
The same pattern applies to APIs where the key is more sensitive. For hosted search with a key that must not be public, the proxy accepts only the query string and a page number, validates both (length, character set, page under 10), forwards to one fixed index, and strips any fields the result page does not render. Cache common queries for a minute; they repeat more than expected. For write APIs — adding an email to a mailing list — never cache, always rate-limit tightly, and verify a Turnstile token first, exactly as in Adding a Contact Form with Cloudflare Workers.
Pitfalls & Rollback
- Open proxies. Forwarding any path or query with your key attached hands your key to everyone. Fix upstream URLs per route.
- No timeout. A slow upstream holds the function and the reader. Set a short timeout and fall back.
- Returning the full upstream payload. It wastes bytes and can leak fields you did not mean to publish. Shape the response.
- Caching errors. Do not cache upstream 4xx and 5xx responses as if they were data; serve the stale copy instead.
- Rollback: the widget's static fallback link means removing the script or the function leaves a working page; restoring either is one file.
Conclusion
An edge proxy lets a static page show live third-party data without exposing keys, without cross-origin trouble and without sending every reader to the upstream API. One route per upstream request, an allow-list for parameters, a trimmed response, a short edge cache with a long-lived fallback copy, and a three-second timeout turned two heavy embeds into 1.1 KB of JSON, cut upstream calls by 99%, and kept the widgets working through an upstream outage.
FAQ
Why not call the third-party API directly from the browser?
Because any API key in front-end code is public, many APIs do not allow cross-origin requests, rate limits apply per key and would be shared by every reader, and responses usually contain far more data than the page needs. A proxy fixes all four.
Is a generic proxy that forwards any path a good idea?
No. A proxy that forwards arbitrary paths with your key attached lets anyone use your key for anything the API allows. Map each proxy route to one fixed upstream request, with only the parameters you intend to allow.
How long should proxied responses be cached?
As long as the data can reasonably be stale. Release lists and status summaries tolerate minutes; prices and stock levels may need seconds. Use stale-while-revalidate so readers get an immediate response while the cache refreshes.
What should happen when the upstream API is down?
Serve the last good response from cache if you have one, or a small, well-formed fallback that the page can render gracefully. Never let an upstream failure turn into a broken page or a hung request.
Related
- Parent: Serverless Functions for Static Sites — functions on static sites in general.
- Third-Party Script Performance on Static Sites — why replacing embeds pays off.
- Netlify Functions vs Cloudflare Workers — where to run the proxy.
- Stale-While-Revalidate for Static HTML — the same caching idea for pages.
- Pagefind vs Algolia DocSearch — when a keyed search API is worth proxying.