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 +21 -0
- package/README.md +412 -0
- package/dist/index.cjs +678 -0
- package/dist/index.d.cts +70 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +651 -0
- package/package.json +65 -0
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
|