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.
Walkthrough
1. Put the key in the environment, not in the client
ORIZN_API_KEY=orizn_visa_...2. The route handler — the complete file
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
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"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, forwardedThe 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.
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.