next-geo-block
v0.2.0
Published
Per-page geo-blocking for Next.js (proxy/middleware) with country-header detection and an optional timezone fallback when no IP-country header is present.
Downloads
32
Maintainers
Readme
next-geo-block
Per-page geo-blocking for Next.js. Block specific countries on specific routes — not everything globally.
- Per-route rules — block
RU/CNonly on/checkout, blockKP/IRonly on/admin, leave everything else open - Works on Vercel out of the box — reads
x-vercel-ip-country. Also readscf-ipcountry(Cloudflare) andx-country-code(generic) - Optional timezone fallback — bundled IANA → ISO 3166-1 map (~250 zones) for hosts without an IP-country header
- Tiny — 11.5 kB packed, zero runtime deps, edge-runtime safe
- Works with
proxy.ts(Next 16.2+) andmiddleware.ts(Next 13–16.1)
npm install next-geo-blockQuick start
// proxy.ts — Next.js 16.2+ (or middleware.ts on 13–16.1)
import { createGeoBlock } from 'next-geo-block'
export const proxy = createGeoBlock({
rules: [
{ paths: ['/checkout', '/checkout/*'], block: ['CN', 'RU'] },
{ paths: ['/admin/*'], block: ['CN', 'RU', 'KP', 'IR'] },
],
})
export const config = {
// Keep `/blocked` and static assets out so the proxy never loops.
matcher: ['/((?!_next|api|blocked|favicon.ico|.*\\..*).*)'],
}Add a app/blocked/page.tsx:
export default function Blocked() {
return <main>This site isn’t available in your region.</main>
}That's it. Blocked countries see your /blocked page at the original URL (clean UX, no redirect). Everyone else sees the real page.
How country detection works
flowchart TD
A[Incoming request] --> B{Path matches a rule?}
B -->|No| Z1[Pass through]
B -->|Yes| C[Try custom headers]
C -->|Found| H[Country resolved]
C -->|None| D[Try x-vercel-ip-country]
D -->|Found, not 'XX'| H
D -->|Missing or 'XX'| E[Try cf-ipcountry]
E -->|Found, not 'XX'| H
E -->|Missing or 'XX'| F[Try x-country-code]
F -->|Found| H
F -->|None| G{fallbackTimezone enabled?}
G -->|No| I{failOpen?}
G -->|Yes| J[Read cookie / header,<br/>map IANA tz → country]
J -->|Resolved| H
J -->|Unknown tz| I
H --> K{Country in rule's block list?}
K -->|No| Z2[Pass through]
K -->|Yes| L[Block: rewrite to /blocked,<br/>or 451, or onBlock]
I -->|Open| Z3[Pass through]
I -->|Closed| LAPI
createGeoBlock(options)
| Option | Type | Default | Notes |
| ------------------- | -------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| rules | GeoBlockRule[] | required | Evaluated in order; first match wins. |
| blockedPath | string \| null | '/blocked' | Path to rewrite blocked requests to. null → return a 451 status instead. |
| failOpen | boolean | true | When no country can be resolved, let through (true) or block (false). |
| onBlock | (ctx) => Response | — | Custom response. Overrides blockedPath. Receives { request, country, pathname, matchedRule, source }. Return NextResponse.next() for soft-block (log-only). |
| countryHeaders | string[] | [] | Extra headers, tried first. Built-ins always tried after. |
| fallbackTimezone | boolean \| TimezoneFallback | false | See Timezone fallback. |
GeoBlockRule
{
paths: Array<string | RegExp> // see Path pattern syntax below
block: string[] // ISO 3166-1 alpha-2, case-insensitive
}Path pattern syntax
| Pattern | Matches | Example match |
| -------------------------------------- | ---------------------------------------- | ----------------------------------- |
| /checkout | exact path (+ trailing slash) | /checkout, /checkout/ |
| /checkout/* | any depth under /checkout | /checkout/cart, /checkout/a/b/c |
| /u/*/settings | single-segment wildcard between literals | /u/alice/settings |
| /^\/api\/v\d+\/private/ (RegExp) | any anchored regex | /api/v2/private/keys |
Use cases
1. E-commerce: block sanctioned countries from checkout
createGeoBlock({
rules: [
{ paths: ['/checkout/*', '/api/checkout/*'], block: ['CU', 'IR', 'KP', 'SY', 'RU'] },
],
})Browsing the catalogue stays open; payment endpoints reject sanctioned regions.
2. SaaS admin lockdown: only your team's regions
Invert it — allow a list, block everything else:
const ALLOWED = new Set(['US', 'CA', 'GB', 'IN'])
createGeoBlock({
rules: [{ paths: ['/admin/*'], block: [] }], // matcher only
onBlock: () => new NextResponse('Forbidden', { status: 403 }),
countryHeaders: [],
failOpen: false, // unknown = blocked
// Custom logic via onBlock:
})For invert-style allowlists, use a tiny custom proxy instead — the package's primary mode is denylist:
import { getCountry } from 'next-geo-block'
import { NextResponse, type NextRequest } from 'next/server'
const ALLOWED = new Set(['US', 'CA', 'GB', 'IN'])
export function proxy(req: NextRequest) {
if (!req.nextUrl.pathname.startsWith('/admin')) return NextResponse.next()
const c = getCountry(req)
if (c && ALLOWED.has(c)) return NextResponse.next()
return NextResponse.rewrite(new URL('/blocked', req.url))
}3. Content licensing: hide region-locked pages
createGeoBlock({
rules: [
{ paths: ['/watch/sports/*'], block: ['US', 'CA'] }, // licence excludes NA
{ paths: ['/watch/anime/*'], block: ['JP'] }, // licence excludes JP
],
blockedPath: '/region-locked',
})4. Compliance-driven hard block (no fail-open)
For GDPR-affected pages or KYC flows where unknown ≠ allowed:
createGeoBlock({
rules: [{ paths: ['/kyc/*'], block: ['IR', 'KP', 'SY', 'CU'] }],
failOpen: false,
blockedPath: null, // return 451 Unavailable For Legal Reasons
fallbackTimezone: true, // best-effort signal when header is missing
})5. Log-only / soft-block (warmup phase)
Roll out without blocking real users — observe what would have been blocked:
import { NextResponse } from 'next/server'
createGeoBlock({
rules: [{ paths: ['/checkout/*'], block: ['RU', 'CN'] }],
onBlock: async ({ country, pathname }) => {
await fetch('https://logs.example.com/geo-soft-block', {
method: 'POST',
body: JSON.stringify({ country, pathname, ts: Date.now() }),
})
return NextResponse.next() // let it through anyway
},
})Flip to a real block (drop the NextResponse.next()) once the volume looks right.
6. Per-environment rules
const blockedCountries =
process.env.NODE_ENV === 'production' ? ['CN', 'RU', 'KP'] : []
createGeoBlock({
rules: [{ paths: ['/checkout/*'], block: blockedCountries }],
})Timezone fallback
Hosts that don't terminate at Vercel/Cloudflare often won't inject an IP-country header. Use the client's local timezone (Intl.DateTimeFormat().resolvedOptions().timeZone) as a secondary signal.
How it actually works
Middleware runs on the edge, before any client JS executes. It can't read Intl.DateTimeFormat() directly — the client has to deliver the timezone over the wire. That means a two-pass model:
sequenceDiagram
participant U as User
participant E as Edge (proxy.ts)
participant S as Server (page)
Note over U,E: First request — no cookie yet
U->>E: GET /checkout
E->>E: No country header,<br/>no tz cookie
E->>S: Pass through (failOpen)
S-->>U: HTML + tz-cookie-setter <script>
Note over U: Browser sets cookie<br/>client_tz=Asia/Kolkata
Note over U,E: Subsequent requests
U->>E: GET /admin (cookie set)
E->>E: Map Asia/Kolkata → IN
E->>U: /blocked content (rewrite)The first request to a blocked path can slip through; every request afterward (same session, same cookie) is enforced. If first-request enforcement matters, don't rely on timezone alone — use a real IP-country header.
Wiring it up (App Router)
app/layout.tsx:
import Script from 'next/script'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<Script id="next-geo-block-tz" strategy="beforeInteractive">
{`try{document.cookie='client_tz='+encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone)+';path=/;max-age=31536000;samesite=lax'}catch(e){}`}
</Script>
</head>
<body>{children}</body>
</html>
)
}Then turn on the fallback:
createGeoBlock({
rules: [{ paths: ['/checkout/*'], block: ['IN'] }],
fallbackTimezone: true, // reads cookie `client_tz` (default)
})API-only routes — header on fetch
If the protected route is a client-side fetch, skip the cookie and send a header:
fetch('/api/private', {
headers: { 'x-client-timezone': Intl.DateTimeFormat().resolvedOptions().timeZone },
})Custom names + custom resolver
createGeoBlock({
rules: [{ paths: ['/admin/*'], block: ['KP'] }],
fallbackTimezone: {
cookie: 'tz',
header: 'x-tz',
resolve: (tz) => myMap[tz] ?? undefined, // override bundled IANA map
},
})Caveats
- Spoofable. A user can clear the cookie or send a fake one. Timezone is a hint, not enforcement.
- First-request lag. The first request has no cookie.
failOpendecides. - Coverage. Bundled map covers all canonical IANA zones + common legacy aliases (
Asia/Calcutta,Europe/Kiev,Asia/Saigon, allAustralia/*state aliases). Unknown zones →undefined. - Disambiguation. A few zones span multiple countries (
Europe/Busingen,America/Kralendijk); the bundled map picks the primary. Pass a customresolvefor your own rules.
Recipes
Hard 451 instead of a rewrite
createGeoBlock({
rules: [{ paths: ['/admin/*'], block: ['KP'] }],
blockedPath: null,
})Custom JSON for API routes
createGeoBlock({
rules: [{ paths: ['/api/*'], block: ['KP'] }],
onBlock: ({ country, pathname, source }) =>
new Response(
JSON.stringify({ error: 'geo_blocked', country, pathname, source }),
{ status: 403, headers: { 'content-type': 'application/json' } },
),
})source is 'header' or 'timezone' — useful for logging which signal triggered the block.
Multiple matchers per rule
createGeoBlock({
rules: [{
paths: ['/checkout', '/checkout/*', '/api/checkout/*', /^\/v\d+\/pay/],
block: ['CN', 'RU'],
}],
})Custom IP-country header (e.g. behind your own CDN)
createGeoBlock({
rules: [{ paths: ['/*'], block: ['KP'] }],
countryHeaders: ['x-my-cdn-country'],
})Comparison
| Capability | next-geo-block | DIY middleware | @vercel/edge geolocation | Cloudflare WAF |
| -------------------------------- | -------------- | -------------- | -------------------------- | -------------- |
| Per-route rules | ✅ | ✅ (you write) | ✅ (you write) | ✅ |
| Vercel + Cloudflare + generic | ✅ | ✅ (you write) | Vercel-only | CF-only |
| Timezone fallback | ✅ | ❌ | ❌ | ❌ |
| Bundled IANA→country map | ✅ | ❌ | ❌ | n/a |
| No external service | ✅ | ✅ | ✅ | ❌ |
| Lines of user code | ~5 | ~30+ | ~15+ | (config UI) |
Production checklist
Before shipping geo-blocking to production:
- [ ] Add a real
/blockedpage that explains what is blocked and who to contact for appeals - [ ] Decide on
failOpenpolicy (defaulttrueis right for most apps;falsefor compliance) - [ ] Confirm your matcher excludes
/blockedand static assets — otherwise infinite rewrite loop - [ ] Test with
curl -H "x-vercel-ip-country: CN"againstnext dev— the header is not set locally - [ ] If using timezone fallback: ship the inline
<Script>and verify the cookie appears in DevTools - [ ] If using
failOpen: false: ensure local dev still works (set a dev header orfailOpen: process.env.NODE_ENV === 'production' ? false : true) - [ ] Log blocks to your observability tool via
onBlockso you can audit false positives - [ ] Review the country list against current sanctions guidance (OFAC SDN, EU restrictive measures) — package ships no opinion on which countries to block
FAQ
Does this work locally?
Yes — but x-vercel-ip-country is only set in production by Vercel's edge. Locally, pass it yourself: curl -H 'x-vercel-ip-country: CN' http://localhost:3000/checkout.
Does this work on the Edge runtime? Yes. Zero Node-only imports. Compiles into Vercel Edge Middleware (~39 kB middleware bundle in a real Next 15 app).
Will this work on Next.js 16?
Yes. Use proxy.ts instead of middleware.ts, export proxy instead of middleware. The config.matcher is identical.
Can users bypass this with a VPN? Yes. Geo-blocking via IP is best-effort. Anyone determined will route around it. Use this for soft enforcement (UX, licensing, sanctions compliance posture), not as your only line of defence.
Can users bypass timezone fallback by clearing the cookie? Yes. Timezone is a hint, never enforcement. See Caveats.
Why does /checkout from a blocked country return 200 instead of a redirect?
The package uses NextResponse.rewrite() — the URL stays /checkout but the response body is /blocked's. This is intentional: it's cleaner UX, doesn't leak which page the user tried to reach, and avoids redirect chains. Set blockedPath: null for a hard 451 instead.
Why is Cloudflare's XX country code treated as missing?
Cloudflare emits XX when they can't determine the country (per their docs). Treating it as a real country would prevent the timezone fallback from firing — XX is not in any rule's block list, so it would just silently pass through and skip the fallback. We treat it as "unknown" instead.
Can I use this without Next.js?
No — it depends on next/server for NextRequest/NextResponse. For raw Node, fork the ~250 lines of source.
How do I keep this in sync with sanctions lists? You don't — the package ships zero country opinions. Pull current lists from OFAC, EU CFSP, etc. and pass them in.
License
MIT
