next-geo-block 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bharadwaj Giridhar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,412 @@
1
+ # next-geo-block
2
+
3
+ > Per-page geo-blocking for Next.js. Block specific countries on specific routes — not everything globally.
4
+
5
+ - **Per-route rules** — block `RU`/`CN` only on `/checkout`, block `KP`/`IR` only on `/admin`, leave everything else open
6
+ - **Works on Vercel out of the box** — reads `x-vercel-ip-country`. Also reads `cf-ipcountry` (Cloudflare) and `x-country-code` (generic)
7
+ - **Optional timezone fallback** — bundled IANA → ISO 3166-1 map (~250 zones) for hosts without an IP-country header
8
+ - **Tiny** — 11.5 kB packed, zero runtime deps, edge-runtime safe
9
+ - **Works with `proxy.ts` (Next 16.2+) and `middleware.ts` (Next 13–16.1)**
10
+
11
+ ```bash
12
+ npm install next-geo-block
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Quick start
18
+
19
+ ```ts
20
+ // proxy.ts — Next.js 16.2+ (or middleware.ts on 13–16.1)
21
+ import { createGeoBlock } from 'next-geo-block'
22
+
23
+ export const proxy = createGeoBlock({
24
+ rules: [
25
+ { paths: ['/checkout', '/checkout/*'], block: ['CN', 'RU'] },
26
+ { paths: ['/admin/*'], block: ['CN', 'RU', 'KP', 'IR'] },
27
+ ],
28
+ })
29
+
30
+ export const config = {
31
+ // Keep `/blocked` and static assets out so the proxy never loops.
32
+ matcher: ['/((?!_next|api|blocked|favicon.ico|.*\\..*).*)'],
33
+ }
34
+ ```
35
+
36
+ Add a `app/blocked/page.tsx`:
37
+
38
+ ```tsx
39
+ export default function Blocked() {
40
+ return <main>This site isn’t available in your region.</main>
41
+ }
42
+ ```
43
+
44
+ That's it. Blocked countries see your `/blocked` page at the original URL (clean UX, no redirect). Everyone else sees the real page.
45
+
46
+ ---
47
+
48
+ ## How country detection works
49
+
50
+ ```mermaid
51
+ flowchart TD
52
+ A[Incoming request] --> B{Path matches a rule?}
53
+ B -->|No| Z1[Pass through]
54
+ B -->|Yes| C[Try custom headers]
55
+ C -->|Found| H[Country resolved]
56
+ C -->|None| D[Try x-vercel-ip-country]
57
+ D -->|Found, not 'XX'| H
58
+ D -->|Missing or 'XX'| E[Try cf-ipcountry]
59
+ E -->|Found, not 'XX'| H
60
+ E -->|Missing or 'XX'| F[Try x-country-code]
61
+ F -->|Found| H
62
+ F -->|None| G{fallbackTimezone enabled?}
63
+ G -->|No| I{failOpen?}
64
+ G -->|Yes| J[Read cookie / header,<br/>map IANA tz → country]
65
+ J -->|Resolved| H
66
+ J -->|Unknown tz| I
67
+ H --> K{Country in rule's block list?}
68
+ K -->|No| Z2[Pass through]
69
+ K -->|Yes| L[Block: rewrite to /blocked,<br/>or 451, or onBlock]
70
+ I -->|Open| Z3[Pass through]
71
+ I -->|Closed| L
72
+ ```
73
+
74
+ ---
75
+
76
+ ## API
77
+
78
+ ### `createGeoBlock(options)`
79
+
80
+ | Option | Type | Default | Notes |
81
+ | ------------------- | -------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
82
+ | `rules` | `GeoBlockRule[]` | **required** | Evaluated in order; first match wins. |
83
+ | `blockedPath` | `string \| null` | `'/blocked'` | Path to rewrite blocked requests to. `null` → return a 451 status instead. |
84
+ | `failOpen` | `boolean` | `true` | When no country can be resolved, let through (`true`) or block (`false`). |
85
+ | `onBlock` | `(ctx) => Response` | — | Custom response. Overrides `blockedPath`. Receives `{ request, country, pathname, matchedRule, source }`. Return `NextResponse.next()` for soft-block (log-only). |
86
+ | `countryHeaders` | `string[]` | `[]` | Extra headers, tried first. Built-ins always tried after. |
87
+ | `fallbackTimezone` | `boolean \| TimezoneFallback` | `false` | See [Timezone fallback](#timezone-fallback). |
88
+
89
+ ### `GeoBlockRule`
90
+
91
+ ```ts
92
+ {
93
+ paths: Array<string | RegExp> // see Path pattern syntax below
94
+ block: string[] // ISO 3166-1 alpha-2, case-insensitive
95
+ }
96
+ ```
97
+
98
+ ### Path pattern syntax
99
+
100
+ | Pattern | Matches | Example match |
101
+ | -------------------------------------- | ---------------------------------------- | ----------------------------------- |
102
+ | `/checkout` | exact path (+ trailing slash) | `/checkout`, `/checkout/` |
103
+ | `/checkout/*` | any depth under `/checkout` | `/checkout/cart`, `/checkout/a/b/c` |
104
+ | `/u/*/settings` | single-segment wildcard between literals | `/u/alice/settings` |
105
+ | `/^\/api\/v\d+\/private/` _(RegExp)_ | any anchored regex | `/api/v2/private/keys` |
106
+
107
+ ---
108
+
109
+ ## Use cases
110
+
111
+ ### 1. E-commerce: block sanctioned countries from checkout
112
+
113
+ ```ts
114
+ createGeoBlock({
115
+ rules: [
116
+ { paths: ['/checkout/*', '/api/checkout/*'], block: ['CU', 'IR', 'KP', 'SY', 'RU'] },
117
+ ],
118
+ })
119
+ ```
120
+
121
+ Browsing the catalogue stays open; payment endpoints reject sanctioned regions.
122
+
123
+ ### 2. SaaS admin lockdown: only your team's regions
124
+
125
+ Invert it — allow a list, block everything else:
126
+
127
+ ```ts
128
+ const ALLOWED = new Set(['US', 'CA', 'GB', 'IN'])
129
+
130
+ createGeoBlock({
131
+ rules: [{ paths: ['/admin/*'], block: [] }], // matcher only
132
+ onBlock: () => new NextResponse('Forbidden', { status: 403 }),
133
+ countryHeaders: [],
134
+ failOpen: false, // unknown = blocked
135
+ // Custom logic via onBlock:
136
+ })
137
+ ```
138
+
139
+ For invert-style allowlists, use a tiny custom proxy instead — the package's primary mode is denylist:
140
+
141
+ ```ts
142
+ import { getCountry } from 'next-geo-block'
143
+ import { NextResponse, type NextRequest } from 'next/server'
144
+
145
+ const ALLOWED = new Set(['US', 'CA', 'GB', 'IN'])
146
+
147
+ export function proxy(req: NextRequest) {
148
+ if (!req.nextUrl.pathname.startsWith('/admin')) return NextResponse.next()
149
+ const c = getCountry(req)
150
+ if (c && ALLOWED.has(c)) return NextResponse.next()
151
+ return NextResponse.rewrite(new URL('/blocked', req.url))
152
+ }
153
+ ```
154
+
155
+ ### 3. Content licensing: hide region-locked pages
156
+
157
+ ```ts
158
+ createGeoBlock({
159
+ rules: [
160
+ { paths: ['/watch/sports/*'], block: ['US', 'CA'] }, // licence excludes NA
161
+ { paths: ['/watch/anime/*'], block: ['JP'] }, // licence excludes JP
162
+ ],
163
+ blockedPath: '/region-locked',
164
+ })
165
+ ```
166
+
167
+ ### 4. Compliance-driven hard block (no fail-open)
168
+
169
+ For GDPR-affected pages or KYC flows where unknown ≠ allowed:
170
+
171
+ ```ts
172
+ createGeoBlock({
173
+ rules: [{ paths: ['/kyc/*'], block: ['IR', 'KP', 'SY', 'CU'] }],
174
+ failOpen: false,
175
+ blockedPath: null, // return 451 Unavailable For Legal Reasons
176
+ fallbackTimezone: true, // best-effort signal when header is missing
177
+ })
178
+ ```
179
+
180
+ ### 5. Log-only / soft-block (warmup phase)
181
+
182
+ Roll out without blocking real users — observe what would have been blocked:
183
+
184
+ ```ts
185
+ import { NextResponse } from 'next/server'
186
+
187
+ createGeoBlock({
188
+ rules: [{ paths: ['/checkout/*'], block: ['RU', 'CN'] }],
189
+ onBlock: async ({ country, pathname }) => {
190
+ await fetch('https://logs.example.com/geo-soft-block', {
191
+ method: 'POST',
192
+ body: JSON.stringify({ country, pathname, ts: Date.now() }),
193
+ })
194
+ return NextResponse.next() // let it through anyway
195
+ },
196
+ })
197
+ ```
198
+
199
+ Flip to a real block (drop the `NextResponse.next()`) once the volume looks right.
200
+
201
+ ### 6. Per-environment rules
202
+
203
+ ```ts
204
+ const blockedCountries =
205
+ process.env.NODE_ENV === 'production' ? ['CN', 'RU', 'KP'] : []
206
+
207
+ createGeoBlock({
208
+ rules: [{ paths: ['/checkout/*'], block: blockedCountries }],
209
+ })
210
+ ```
211
+
212
+ ---
213
+
214
+ ## Timezone fallback
215
+
216
+ 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.
217
+
218
+ ### How it actually works
219
+
220
+ 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:
221
+
222
+ ```mermaid
223
+ sequenceDiagram
224
+ participant U as User
225
+ participant E as Edge (proxy.ts)
226
+ participant S as Server (page)
227
+ Note over U,E: First request — no cookie yet
228
+ U->>E: GET /checkout
229
+ E->>E: No country header,<br/>no tz cookie
230
+ E->>S: Pass through (failOpen)
231
+ S-->>U: HTML + tz-cookie-setter <script>
232
+ Note over U: Browser sets cookie<br/>client_tz=Asia/Kolkata
233
+ Note over U,E: Subsequent requests
234
+ U->>E: GET /admin (cookie set)
235
+ E->>E: Map Asia/Kolkata → IN
236
+ E->>U: /blocked content (rewrite)
237
+ ```
238
+
239
+ 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.
240
+
241
+ ### Wiring it up (App Router)
242
+
243
+ `app/layout.tsx`:
244
+
245
+ ```tsx
246
+ import Script from 'next/script'
247
+
248
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
249
+ return (
250
+ <html>
251
+ <head>
252
+ <Script id="next-geo-block-tz" strategy="beforeInteractive">
253
+ {`try{document.cookie='client_tz='+encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone)+';path=/;max-age=31536000;samesite=lax'}catch(e){}`}
254
+ </Script>
255
+ </head>
256
+ <body>{children}</body>
257
+ </html>
258
+ )
259
+ }
260
+ ```
261
+
262
+ Then turn on the fallback:
263
+
264
+ ```ts
265
+ createGeoBlock({
266
+ rules: [{ paths: ['/checkout/*'], block: ['IN'] }],
267
+ fallbackTimezone: true, // reads cookie `client_tz` (default)
268
+ })
269
+ ```
270
+
271
+ ### API-only routes — header on `fetch`
272
+
273
+ If the protected route is a client-side `fetch`, skip the cookie and send a header:
274
+
275
+ ```ts
276
+ fetch('/api/private', {
277
+ headers: { 'x-client-timezone': Intl.DateTimeFormat().resolvedOptions().timeZone },
278
+ })
279
+ ```
280
+
281
+ ### Custom names + custom resolver
282
+
283
+ ```ts
284
+ createGeoBlock({
285
+ rules: [{ paths: ['/admin/*'], block: ['KP'] }],
286
+ fallbackTimezone: {
287
+ cookie: 'tz',
288
+ header: 'x-tz',
289
+ resolve: (tz) => myMap[tz] ?? undefined, // override bundled IANA map
290
+ },
291
+ })
292
+ ```
293
+
294
+ ### Caveats
295
+
296
+ - **Spoofable.** A user can clear the cookie or send a fake one. Timezone is a hint, not enforcement.
297
+ - **First-request lag.** The first request has no cookie. `failOpen` decides.
298
+ - **Coverage.** Bundled map covers all canonical IANA zones + common legacy aliases (`Asia/Calcutta`, `Europe/Kiev`, `Asia/Saigon`, all `Australia/*` state aliases). Unknown zones → `undefined`.
299
+ - **Disambiguation.** A few zones span multiple countries (`Europe/Busingen`, `America/Kralendijk`); the bundled map picks the primary. Pass a custom `resolve` for your own rules.
300
+
301
+ ---
302
+
303
+ ## Recipes
304
+
305
+ ### Hard 451 instead of a rewrite
306
+
307
+ ```ts
308
+ createGeoBlock({
309
+ rules: [{ paths: ['/admin/*'], block: ['KP'] }],
310
+ blockedPath: null,
311
+ })
312
+ ```
313
+
314
+ ### Custom JSON for API routes
315
+
316
+ ```ts
317
+ createGeoBlock({
318
+ rules: [{ paths: ['/api/*'], block: ['KP'] }],
319
+ onBlock: ({ country, pathname, source }) =>
320
+ new Response(
321
+ JSON.stringify({ error: 'geo_blocked', country, pathname, source }),
322
+ { status: 403, headers: { 'content-type': 'application/json' } },
323
+ ),
324
+ })
325
+ ```
326
+
327
+ `source` is `'header'` or `'timezone'` — useful for logging which signal triggered the block.
328
+
329
+ ### Multiple matchers per rule
330
+
331
+ ```ts
332
+ createGeoBlock({
333
+ rules: [{
334
+ paths: ['/checkout', '/checkout/*', '/api/checkout/*', /^\/v\d+\/pay/],
335
+ block: ['CN', 'RU'],
336
+ }],
337
+ })
338
+ ```
339
+
340
+ ### Custom IP-country header (e.g. behind your own CDN)
341
+
342
+ ```ts
343
+ createGeoBlock({
344
+ rules: [{ paths: ['/*'], block: ['KP'] }],
345
+ countryHeaders: ['x-my-cdn-country'],
346
+ })
347
+ ```
348
+
349
+ ---
350
+
351
+ ## Comparison
352
+
353
+ | Capability | next-geo-block | DIY middleware | `@vercel/edge` geolocation | Cloudflare WAF |
354
+ | -------------------------------- | -------------- | -------------- | -------------------------- | -------------- |
355
+ | Per-route rules | ✅ | ✅ (you write) | ✅ (you write) | ✅ |
356
+ | Vercel + Cloudflare + generic | ✅ | ✅ (you write) | Vercel-only | CF-only |
357
+ | Timezone fallback | ✅ | ❌ | ❌ | ❌ |
358
+ | Bundled IANA→country map | ✅ | ❌ | ❌ | n/a |
359
+ | No external service | ✅ | ✅ | ✅ | ❌ |
360
+ | Lines of user code | ~5 | ~30+ | ~15+ | (config UI) |
361
+
362
+ ---
363
+
364
+ ## Production checklist
365
+
366
+ Before shipping geo-blocking to production:
367
+
368
+ - [ ] Add a real `/blocked` page that explains *what* is blocked and *who* to contact for appeals
369
+ - [ ] Decide on `failOpen` policy (default `true` is right for most apps; `false` for compliance)
370
+ - [ ] Confirm your matcher excludes `/blocked` and static assets — otherwise infinite rewrite loop
371
+ - [ ] Test with `curl -H "x-vercel-ip-country: CN"` against `next dev` — the header is **not** set locally
372
+ - [ ] If using timezone fallback: ship the inline `<Script>` and verify the cookie appears in DevTools
373
+ - [ ] If using `failOpen: false`: ensure local dev still works (set a dev header or `failOpen: process.env.NODE_ENV === 'production' ? false : true`)
374
+ - [ ] Log blocks to your observability tool via `onBlock` so you can audit false positives
375
+ - [ ] Review the country list against current sanctions guidance (OFAC SDN, EU restrictive measures) — package ships no opinion on which countries to block
376
+
377
+ ---
378
+
379
+ ## FAQ
380
+
381
+ **Does this work locally?**
382
+ 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`.
383
+
384
+ **Does this work on the Edge runtime?**
385
+ Yes. Zero Node-only imports. Compiles into Vercel Edge Middleware (~39 kB middleware bundle in a real Next 15 app).
386
+
387
+ **Will this work on Next.js 16?**
388
+ Yes. Use `proxy.ts` instead of `middleware.ts`, export `proxy` instead of `middleware`. The `config.matcher` is identical.
389
+
390
+ **Can users bypass this with a VPN?**
391
+ 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.
392
+
393
+ **Can users bypass timezone fallback by clearing the cookie?**
394
+ Yes. Timezone is a hint, never enforcement. See [Caveats](#caveats).
395
+
396
+ **Why does `/checkout` from a blocked country return 200 instead of a redirect?**
397
+ 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.
398
+
399
+ **Why is Cloudflare's `XX` country code treated as missing?**
400
+ 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.
401
+
402
+ **Can I use this without Next.js?**
403
+ No — it depends on `next/server` for `NextRequest`/`NextResponse`. For raw Node, fork the ~250 lines of source.
404
+
405
+ **How do I keep this in sync with sanctions lists?**
406
+ You don't — the package ships zero country opinions. Pull current lists from [OFAC](https://sanctionssearch.ofac.treas.gov/), [EU CFSP](https://www.sanctionsmap.eu/), etc. and pass them in.
407
+
408
+ ---
409
+
410
+ ## License
411
+
412
+ MIT