# Next.js route handler som proxy for /autocomplete

> Hold API-nøglen på serveren med en Next.js App Router route handler, der proxyer /autocomplete med cache-headers og sender rate-limit-headers videre.
>
> https://danadresse.dk/guides/nextjs-proxy · verified 2026-11-02
En proxy er et lille endpoint på din egen server. Browseren kalder det uden nøgle, og serveren kalder `https://api.danadresse.dk` med nøglen fra en miljøvariabel. Nøglen kommer aldrig ud i browseren, og du bestemmer selv, hvilke parametre der må sendes videre.

Det er alternativet til en domænelåst browsernøgle (se [Browsernøgler og tilladte domæner](/guides/browser-noegler-domaener)). Vælg proxyen, når du ikke vil have en nøgle i sidens kildekode, når du vil cache svar på din egen CDN, eller når du senere vil kalde endpoints, som browsernøgler ikke må bruge.

## 1. Læg nøglen i en servervariabel

Brug en almindelig nøgle uden tilladte domæner. Variablen må **ikke** have præfikset `NEXT_PUBLIC_`, for så bliver den skrevet ind i klientbundtet.

```bash id=env-local run=no
# .env.local (kun på serveren)
DANADRESSE_API_KEY=dawa_live_din_servernøgle
```

## 2. Skriv route handleren

Filen `app/api/adresse/route.ts` svarer på `GET /api/adresse?q=…`. Den gør fire ting:

- Sender kun en fast liste af parametre videre. Uden den liste kunne enhver bruge din proxy til at kalde andre endpoints på din nøgle.
- Begrænser `per_side` og længden af `q`.
- Sætter `Cache-Control`, så din CDN kan genbruge svaret. Svaret afhænger kun af parametrene, ikke af hvem der spørger, så det er sikkert at cache offentligt hos dig. API'et selv sender `private, max-age=300`, fordi svaret der er knyttet til en nøgle.
- Sender rate-limit-headers videre, så klienten kan se, hvornår den skal vente.

```ts id=route-handler run=no
// 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 betyder, at din servernøgle er forkert eller spærret. Det er din fejl,
  // ikke brugerens: log den og svar 502 uden at vise API'ets fejltekst.
  if (res.status === 401 || res.status === 403) {
    console.error("Danadresse afviste servernøglen:", 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 });
}
```

Route handleren læser `req.nextUrl`, så Next.js behandler den som dynamisk og cacher den ikke selv. Cachen ligger i `Cache-Control`-headeren, som din CDN (fx Vercel) respekterer.

## 3. Kald proxyen fra klienten

Klienten bruger samme kode som i [Adressefelt i React](/guides/react-autocomplete). Skift URL'en, og fjern headeren:

```tsx id=client-fetch run=no
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 vent = Number(res.headers.get("Retry-After") ?? 5);
  // vis "prøv igen om lidt" og vent mindst `vent` sekunder
}
```

Proxyen sender `Retry-After`, `X-RateLimit-*`-headerne og `X-Quota-Grace`/`X-Quota-Remaining` videre, så klienten læser dem præcis som ved et direkte kald til API'et.

## 4. Sæt din egen grænse pr. besøgende

Med en proxy deler alle besøgende én nøgle. Kontoens minutgrænse gælder derfor for hele dit site samlet, og den grænse pr. besøgende, som browsernøgler har, gælder ikke. Ét script, der hamrer løs på `/api/adresse`, kan bruge hele din kvote.

Sæt derfor en grænse pr. IP i din proxy. På Vercel kan du bruge Middleware med et Redis-baseret tællerbibliotek. I en almindelig Node-server er en tæller i hukommelsen nok, så længe du kører én proces. Se Express-eksemplet nedenfor.

Bemærk også, at CDN-cachede svar har de `X-RateLimit-*`-tal, der gjaldt, da svaret blev hentet. Brug dem som et fingerpeg, ikke som sandhed.

## 5. Samme proxy i Express

Har du ikke Next.js, gør en Express-server det samme. Eksemplet kræver Node 18 eller nyere (indbygget `fetch`) og `npm install express`:

```js id=express-proxy run=syntax
import express from "express";

const app = express();
app.set("trust proxy", 1); // læs klientens IP fra X-Forwarded-For bag din 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",
];

// Enkel tæller pr. IP i hukommelsen (kun til én proces).
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 afviste servernøglen:", 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. Se de headers, proxyen sender videre

Kald API'et direkte fra Node og se de headers, din proxy skal videresende. `X-RateLimit-Reset` er et Unix-tidsstempel for månedens nulstilling, og `X-RateLimit-Minute-Reset` er tidspunktet, hvor minutvinduet starter forfra:

```js id=upstream-headers run=live expect="x-ratelimit-limit"
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}`);
  }
}
```

Svaret ser nogenlunde sådan ud (tallene afhænger af din plan):

```text id=upstream-headers-output run=no
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
```

På Enterprise er `X-RateLimit-Minute-Limit` sat til `unlimited`, og der er ingen `Minute-Remaining` eller `Minute-Reset`. En gratiskonto, der har brugt sin månedskvote, får stadig svar op til 20 % mere; de svar har `X-Quota-Grace: true` og `X-Quota-Remaining` (kald tilbage i bufferen). Sådan håndterer du en 429 korrekt: [Grænser, kvoter og 429](/guides/graenser-og-429).

## Næste skridt

- [Grænser, kvoter og 429](/guides/graenser-og-429)
- [Caching](/guides/caching) — hvad du må cache, og hvor længe.
- [Browsernøgler og tilladte domæner](/guides/browser-noegler-domaener) — alternativet uden server.
- Oversigt: [/nextjs-adresse-api](/nextjs-adresse-api).
