Next.js route handler as a proxy for /autocomplete
Keep the API key on the server with a Next.js App Router route handler that proxies /autocomplete with cache headers and passes rate-limit headers through.
A proxy is a small endpoint on your own server. The browser calls it without a key, and the server calls https://api.danadresse.dk with the key from an environment variable. The key never reaches the browser, and you decide which parameters may be passed on.
It is the alternative to a domain-locked browser key (see Browser keys and allowed domains). Choose the proxy when you do not want a key in your page source, when you want to cache answers on your own CDN, or when you later want to call endpoints that browser keys may not use.
1. Put the key in a server variable
Use a regular key with no allowed domains. The variable must not be prefixed NEXT_PUBLIC_, or it gets written into the client bundle.
# .env.local (server only)
DANADRESSE_API_KEY=dawa_live_your_server_key2. Write the route handler
The file app/api/adresse/route.ts answers GET /api/adresse?q=…. It does four things:
- Passes on only a fixed list of parameters. Without that list anyone could use your proxy to call other endpoints on your key.
- Limits
per_sideand the length ofq. - Sets
Cache-Controlso your CDN can reuse the answer. The answer depends only on the parameters, not on who asks, so it is safe to cache publicly on your side. The API itself sendsprivate, max-age=300, because its answer is tied to a key. - Passes rate-limit headers through, so the client can see when to wait.
// app/api/adresse/route.ts import type { NextRequest } from "next/server"; const UPSTREAM = "https://api.danadresse.dk/autocomplete"; const TILLADTE_PARAMETRE = ["q", "type", "postnr", "kommunekode", "adgangsadresseid", "caretpos", "fuzzy", "srid"]; const VIDERESEND = [ "x-ratelimit-limit", "x-ratelimit-remaining", "x-ratelimit-reset", "x-ratelimit-minute-limit", "x-ratelimit-minute-remaining", "x-ratelimit-minute-reset", "retry-after", "x-quota-grace", "x-quota-remaining", ]; export async function GET(req: NextRequest) { const ind = req.nextUrl.searchParams; const q = (ind.get("q") ?? "").trim(); if (q.length < 2 || q.length > 120) { return Response.json([], { headers: { "Cache-Control": "public, max-age=60" } }); } const url = new URL(UPSTREAM); for (const navn of TILLADTE_PARAMETRE) { const v = ind.get(navn); if (v !== null) url.searchParams.set(navn, v); } const perSide = Math.min(Math.max(Number(ind.get("per_side")) || 8, 1), 20); url.searchParams.set("per_side", String(perSide)); let res: Response; try { res = await fetch(url, { headers: { "X-Api-Key": process.env.DANADRESSE_API_KEY! }, cache: "no-store", signal: AbortSignal.timeout(5000), }); } catch { return Response.json( { error: "upstream_unavailable" }, { status: 503, headers: { "Retry-After": "5", "Cache-Control": "no-store" } }, ); } // 401/403 means your server key is wrong or blocked. That is your fault, // not the user's: log it and answer 502 without showing the API's error text. if (res.status === 401 || res.status === 403) { console.error("Danadresse rejected the server key:", res.status, await res.text()); return Response.json({ error: "bad_gateway" }, { status: 502, headers: { "Cache-Control": "no-store" } }); } const headers = new Headers({ "Content-Type": "application/json; charset=utf-8" }); for (const h of VIDERESEND) { const v = res.headers.get(h); if (v) headers.set(h, v); } headers.set( "Cache-Control", res.ok ? "public, max-age=60, s-maxage=300, stale-while-revalidate=600" : "no-store", ); return new Response(res.body, { status: res.status, headers }); }
The route handler reads req.nextUrl, so Next.js treats it as dynamic and does not cache it itself. Caching is driven by the Cache-Control header, which your CDN (for example Vercel) respects.
3. Call the proxy from the client
The client uses the same code as in Address field in React. Change the URL and drop the header:
const url = new URL("/api/adresse", window.location.origin); url.searchParams.set("q", q); url.searchParams.set("type", "adresse"); const res = await fetch(url, { signal: ctrl.signal }); if (res.status === 429) { const wait = Number(res.headers.get("Retry-After") ?? 5); // show "try again shortly" and wait at least `wait` seconds }
The proxy forwards Retry-After, the X-RateLimit-* headers and X-Quota-Grace/X-Quota-Remaining, so the client reads them exactly as on a direct call to the API.
4. Set your own per-visitor limit
With a proxy all visitors share one key. The account's per-minute limit therefore applies to your whole site combined, and the per-visitor cap that browser keys have does not apply. One script hammering /api/adresse can use up your entire quota.
So set a per-IP limit in your proxy. On Vercel you can use Middleware with a Redis-backed counter library. In a plain Node server an in-memory counter is enough as long as you run a single process. See the Express example below.
Also note that CDN-cached answers carry the X-RateLimit-* values from when they were fetched. Treat them as a hint, not as the truth.
5. The same proxy in Express
Without Next.js, an Express server does the same job. The example needs Node 18 or later (built-in fetch) and npm install express:
import express from "express"; const app = express(); app.set("trust proxy", 1); // read the client IP from X-Forwarded-For behind your load balancer const UPSTREAM = "https://api.danadresse.dk/autocomplete"; const TILLADTE_PARAMETRE = ["q", "type", "postnr", "kommunekode", "adgangsadresseid", "caretpos", "fuzzy", "srid"]; const VIDERESEND = [ "x-ratelimit-limit", "x-ratelimit-remaining", "x-ratelimit-reset", "x-ratelimit-minute-limit", "x-ratelimit-minute-remaining", "x-ratelimit-minute-reset", "retry-after", "x-quota-grace", "x-quota-remaining", ]; // Simple in-memory counter per IP (single process only). const VINDUE_MS = 60_000; const MAKS_PR_VINDUE = 60; const taeller = new Map(); setInterval(() => taeller.clear(), VINDUE_MS).unref(); function graensePrIp(req, res, next) { const n = (taeller.get(req.ip) ?? 0) + 1; taeller.set(req.ip, n); if (n > MAKS_PR_VINDUE) { res.set("Retry-After", "10").status(429).json({ error: "too_many_requests" }); return; } next(); } app.get("/api/adresse", graensePrIp, async (req, res) => { const q = String(req.query.q ?? "").trim(); if (q.length < 2 || q.length > 120) { res.set("Cache-Control", "public, max-age=60").json([]); return; } const url = new URL(UPSTREAM); for (const navn of TILLADTE_PARAMETRE) { if (typeof req.query[navn] === "string") url.searchParams.set(navn, req.query[navn]); } const perSide = Math.min(Math.max(Number(req.query.per_side) || 8, 1), 20); url.searchParams.set("per_side", String(perSide)); try { const upstream = await fetch(url, { headers: { "X-Api-Key": process.env.DANADRESSE_API_KEY }, signal: AbortSignal.timeout(5000), }); if (upstream.status === 401 || upstream.status === 403) { console.error("Danadresse rejected the server key:", upstream.status, await upstream.text()); res.set("Cache-Control", "no-store").status(502).json({ error: "bad_gateway" }); return; } for (const h of VIDERESEND) { const v = upstream.headers.get(h); if (v) res.set(h, v); } res.set("Cache-Control", upstream.ok ? "public, max-age=60, s-maxage=300" : "no-store"); res.status(upstream.status).type("application/json").send(await upstream.text()); } catch { res.set({ "Retry-After": "5", "Cache-Control": "no-store" }).status(503).json({ error: "upstream_unavailable" }); } }); app.listen(process.env.PORT ?? 3000);
6. See the headers the proxy passes on
Call the API directly from Node and look at the headers your proxy should forward. X-RateLimit-Reset is a Unix timestamp for the monthly reset, and X-RateLimit-Minute-Reset is when the minute window starts over:
const url = new URL("https://api.danadresse.dk/autocomplete"); url.searchParams.set("q", "Strandvejen 100"); url.searchParams.set("per_side", "3"); const res = await fetch(url, { headers: { "X-Api-Key": process.env.DANADRESSE_API_KEY }, }); console.log("status:", res.status); for (const [navn, vaerdi] of res.headers) { if (navn.startsWith("x-ratelimit") || navn === "cache-control") { console.log(`${navn}: ${vaerdi}`); } }
The output looks roughly like this (the numbers depend on your plan):
status: 200 cache-control: private, max-age=300 x-ratelimit-limit: 2000 x-ratelimit-minute-limit: 360 x-ratelimit-minute-remaining: … x-ratelimit-minute-reset: 1792065660 x-ratelimit-remaining: … x-ratelimit-reset: 1793577600
On Enterprise X-RateLimit-Minute-Limit is unlimited, and there is no Minute-Remaining or Minute-Reset. A free account that has passed its monthly quota is still served up to 20 % more; those answers carry X-Quota-Grace: true and X-Quota-Remaining (calls left in the buffer). How to handle a 429 properly: Limits, quotas and 429.
Next steps
- Limits, quotas and 429
- Caching — what you may cache, and for how long.
- Browser keys and allowed domains — the serverless alternative.
- Overview: /en/nextjs-adresse-api.
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)