Route handler · server-side

Next.js

Keep the key on the server. One route handler, and the browser only ever talks to your own domain.

What was executed to write this page

The route handler below was run in a real Next.js dev server on localhost and curled on three paths — a valid pair, a missing parameter, and alpha-2 codes. The output blocks are that server's actual responses.

Before you start

  • Node 18.18 or later; Node 20+ recommended.
  • A Next.js App Router project.
  • An Orizn API key. Free tier: 50 requests/month, no credit card — get one at visa.orizn.app/visa-api. It is 5 requests until you confirm your email, so click the link in the confirmation mail.
Get a free API key

Walkthrough

1. Put the key in the environment, not in the client

.env.local
ORIZN_API_KEY=orizn_visa_...

2. The route handler — the complete file

app/api/visa/route.ts
import { NextRequest, NextResponse } from 'next/server'

const KEY_URL = 'https://visa.orizn.app/visa-api'

export async function GET(req: NextRequest) {
  const passport = req.nextUrl.searchParams.get('passport')
  const destination = req.nextUrl.searchParams.get('destination')

  if (!passport || !destination) {
    return NextResponse.json({ error: 'Missing passport or destination' }, { status: 400 })
  }

  // The API requires a key on every endpoint — fail with the signup link rather
  // than forwarding a keyless request and showing the user a bare 401.
  const apiKey = process.env.ORIZN_API_KEY
  if (!apiKey) {
    return NextResponse.json(
      { error: `Set ORIZN_API_KEY in .env.local — free key (50 req/month) at ${KEY_URL}` },
      { status: 500 }
    )
  }

  const res = await fetch(
    `https://visa.orizn.app/api/v1/visa/check?passport=${encodeURIComponent(passport)}` +
      `&destination=${encodeURIComponent(destination)}`,
    { headers: { 'x-api-key': apiKey } }
  )

  const data = await res.json()
  // Forward the upstream status: a 401/429 must not reach the browser as a 200.
  return NextResponse.json(data, { status: res.status })
}

3. Run it and check all three paths

Terminal
npm run dev

curl -s "http://localhost:3000/api/visa?passport=FRA&destination=JPN"
curl -s "http://localhost:3000/api/visa?passport=FRA"
curl -s "http://localhost:3000/api/visa?passport=FR&destination=JP"
Output · Real responses from the running dev server
HTTP 200
{"passport":"FRA","destination":"JPN","requirement":"visa_free",
 "visa_free_days":90,"visa_required":false,"last_verified":"2026-05-08", ...}

HTTP 400
{"error":"Missing passport or destination"}

HTTP 400
{"error":"Required: ?passport=FRA&destination=JPN (ISO3 codes)"}

Two different 400s, and that is the point. The first is your handler rejecting a malformed request before spending quota. The second is the API's own message, forwarded through with its status intact.

4. Call it from a page

The browser calls your route, never Orizn. Your domain, your CORS, your key.

const res = await fetch(`/api/visa?passport=${p}&destination=${d}`);
const data = await res.json();

if (!res.ok) {
  // data.error is already a sentence you can render
  setError(data.error);
} else {
  setResult(data);
}

When it goes wrong

Every failure the integration can hand you, with the message it actually prints.

ORIZN_API_KEY missing from .env.local

500, raised by your own handler
{"error":"Set ORIZN_API_KEY in .env.local — free key (50 req/month) at https://visa.orizn.app/visa-api"}

Deliberate: failing with an actionable message beats forwarding a keyless request and showing a bare 401. Restart the dev server after editing .env.local.

Missing passport or destination

400, before any upstream call
{"error":"Missing passport or destination"}

Costs no quota.

Alpha-2 codes

400, forwarded from the API
{"error":"Required: ?passport=FRA&destination=JPN (ISO3 codes)"}

Validate the shape in the handler if you want to save the round-trip.

Key rejected upstream

403, forwarded
{"error":"Invalid API key", ...}

Because the status is forwarded, your client-side if (!res.ok) actually fires. This is the line people delete and regret.

Quota spent upstream

429, forwarded
The API's message, with the upgrade URL.

Consider caching by pair — the answer changes on the order of weeks, not seconds.

The 500, and both 400 rows were reproduced against a running dev server. The 403 and 429 rows follow from the same forwarding line but were not triggered end to end.

End to end: entry requirements in a booking flow

The handler above answers one pair. Two changes make it production-shaped.

  • Cache by pair. Visa rules change on the order of weeks. A revalidate on the fetch, or any KV in front of the handler, turns thousands of bookings into a handful of requests — and the quota is monthly.
  • Switch to /api/v1/visa once you show anything beyond yes/no. The document list is what travellers actually need, and it is the same key and the same request cost.
Caching, the lazy version
const res = await fetch(url, {
  headers: { 'x-api-key': apiKey },
  next: { revalidate: 86400 },   // one day; rules move slower than that
})

And keep the 404 honest: no record for a pair means unknown, not visa-free. Rendering “no visa needed” for an unknown pair is the one failure mode with a real-world cost.

Source & reference

Other integrations

50 requests a month, no credit card

All 15 languages included on the free plan. Hit a wall with this tutorial? Mail [email protected] — a tutorial that does not work is a bug.