npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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/CN only on /checkout, block KP/IR only on /admin, leave everything else open
  • Works on Vercel out of the box — reads x-vercel-ip-country. Also reads cf-ipcountry (Cloudflare) and x-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+) and middleware.ts (Next 13–16.1)
npm install next-geo-block

Quick 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| L

API

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. failOpen decides.
  • Coverage. Bundled map covers all canonical IANA zones + common legacy aliases (Asia/Calcutta, Europe/Kiev, Asia/Saigon, all Australia/* state aliases). Unknown zones → undefined.
  • 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.

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 /blocked page that explains what is blocked and who to contact for appeals
  • [ ] Decide on failOpen policy (default true is right for most apps; false for compliance)
  • [ ] Confirm your matcher excludes /blocked and static assets — otherwise infinite rewrite loop
  • [ ] Test with curl -H "x-vercel-ip-country: CN" against next 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 or failOpen: process.env.NODE_ENV === 'production' ? false : true)
  • [ ] Log blocks to your observability tool via onBlock so 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