A web backlink checker in Next.js, key kept on the server
CORS is open, so a browser can call the API directly. Doing it with a paid key is the mistake. This page shows the thin server layer that fixes it.
Myth versus what happens
| Myth | Reality |
|---|---|
| "Open CORS means I can ship the key in my React bundle." | Open CORS means the call works. Anyone can still read the key from the network tab and spend your balance. |
| "A table of links must render on the client because it is a dashboard." | Fetch on the server and send HTML. Your user sees rows on first paint and your key never leaves the box. |
| "Free actions need protection too." | domain, overview, stats and suggest need no key, so a browser may call them directly. |
| "I should refetch on every page view." | The index rebuilds about monthly. A 24-hour cache loses nothing. |
| "The proxy should forward whatever the client sends." | Allow-list the actions you use. A forwarded action=search costs 3 credits. |
The route handler
App Router, file app/api/links/route.ts. The environment variable never reaches the client bundle because it has no NEXT_PUBLIC_ prefix.
import { NextRequest, NextResponse } from "next/server";
const ALLOWED = new Set(["refdomains", "anchors", "toppages"]);
export async function GET(req: NextRequest) {
const p = req.nextUrl.searchParams;
const action = p.get("action") ?? "";
const q = (p.get("q") ?? "").toLowerCase();
if (!ALLOWED.has(action) || !/^[a-z0-9.-]+$/.test(q)) {
return NextResponse.json({ ok: false, error: "bad request" }, { status: 400 });
}
const url = `https://seomasters.dev/api.php?action=${action}&q=${q}&per=100`;
const upstream = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SEO_API_KEY}` },
next: { revalidate: 86400 },
});
if (upstream.status === 402) {
return NextResponse.json({ ok: false, error: "data temporarily unavailable" }, { status: 503 });
}
return new NextResponse(upstream.body, { status: upstream.status, headers: { "content-type": "application/json" } });
}Why the handler looks like this
Allow-list of actions
A bug in your UI should not be able to trigger a 3-credit search call by accident.
Regex on q
Only a bare hostname gets through. No scheme, no path, no query string smuggled upstream.
revalidate: 86400
One upstream call per domain per day. With monthly data, longer is also fine.
402 mapped to 503
Your visitors do not need to learn that your balance is empty. You do, so log it.
Rendering: what a crawler and a customer both see
If this dashboard sits behind a login, server or client rendering is a UX choice. If any of it is public, say a free lookup page you want indexed, render the first table on the server.
A page that arrives as an empty shell and fills in after hydration is a risk for indexing and for slow phones alike. Google can execute JavaScript, but it queues that step and does not always get to it quickly.
Put the cheap, free overview call in a Server Component and let the visitor trigger the paid, deeper pulls from a button. That split also keeps your credit spend predictable.
Wiring the numbers in is the easy part. Explaining them is harder, so link the DR formula from the tooltip and mention that the index refreshes monthly.
Hardening, in the order we would do it
Open Chrome DevTools on your own dashboard and read the Network tab. Look closely. If a request there shows sk_live_ anywhere, rotate the key today. That check takes a minute and finds the commonest leak.
Next, add a per-user limit. Put it in front of the route. The handler above spends your credit for anyone who can reach it, so tie it to a session and cap calls per hour. Pick the cap from the budget maths on the credit page.
Also worth a line in the runbook: who gets paged when the balance drops below a set level, and who tops it up. Name the person.
Then log _credits from each upstream response into your own metrics. A graph of remaining balance, sampled daily, warns you a week before HTTP 402 does.
One more habit pays off. Keep the allow-list in a shared constant, imported by the handler and by the tests, so a new action cannot reach production without someone editing a visible list and a reviewer noticing it.
Last, run the handler under Node 20 or the edge runtime of your choice and test both. Streaming the upstream body, as the code does, works in each, but header handling differs and is worth a test.