Edge Compute, Feature Flags, and Geo-Routing25 min read
Module A-9·25 min read
Edge runtime constraints and size limits, the Prisma/Edge incompatibility and why Drizzle + Neon serverless is the fix, middleware performance profiling, feature flag architecture with GrowthBook at the edge, A/B testing without layout shift, and geo-routing at the CDN layer.
A-9 — Edge Compute, Feature Flags, and Geo-Routing
Who this is for: Architects building applications that need to behave differently based on who's requesting — where they are in the world, what experiment they're in, what role they hold. This module is about doing that decision-making at the edge, before the request ever reaches your Next.js server, so the cost of personalisation is milliseconds not seconds.
What "Edge" Actually Means in Practice
"Edge compute" has become a buzzword. The concrete meaning: code that runs in V8 isolates deployed at CDN edge nodes globally — not in a central server region. A request from Tokyo runs your edge code in a Tokyo datacenter, not in us-east-1.
The V8 isolate model has specific properties:
Near-zero cold start — V8 isolates start in microseconds, not the 100-300ms of a Node.js cold start
Geographic proximity — code runs close to users, eliminating cross-ocean RTT
No Node.js APIs — fs, crypto, most npm packages don't work. Only Web Standard APIs
1MB bundle limit — your Middleware code must be tiny
In Next.js, edge compute surfaces in two places:
Middleware (middleware.ts) — runs at the edge for every request before routing
Middleware is the primary edge primitive. It's where geo-routing, feature flags, and auth checks belong — before the request reaches any Server Component.
Middleware Architecture — What to Put There
Middleware is fast and runs everywhere. That makes it tempting to put everything in it. The discipline: Middleware is for routing decisions, not business logic.
The right things to do in Middleware:
Read a cookie or header and rewrite/redirect the request
Check authentication status and redirect to login
Set response headers (security headers, CORS)
Geo-routing (redirect based on country)
A/B test assignment (assign a variant cookie, rewrite to a variant path)
The wrong things to do in Middleware:
Database queries (no Prisma, no connection pooling at the edge)
Complex cryptographic operations
Importing large npm packages
Business logic that should be in Server Components
ts
The matcher config limits which paths Middleware runs on. This is critical for performance — running Middleware on _next/static requests is wasteful. The regex /((?!api|_next/static|_next/image|favicon.ico).*) excludes all static assets.
Geo-Routing in Production
Vercel injects the user's geographic data into the request as headers. NextRequest.geo was removed from Next.js 15 — read this data with geolocation(request) from the @vercel/functions package instead. It returns:
country — ISO 3166-1 alpha-2 country code (e.g., 'US', 'DE', 'JP')
region — region/state code (e.g., 'CA' for California)
city — city name
latitude / longitude — approximate coordinates
ts
The difference between redirect and rewrite:
redirect — changes the URL in the browser. User sees they're on a different domain/path.
rewrite — serves different content but keeps the same URL. Invisible to the user.
Rewrites are the right choice for localised content at the same URL. Redirects are right when users genuinely need to be on a different domain (legal requirement, different service).
Feature Flags at the Edge
Feature flags at the edge — assigning flag values in Middleware and reading them in Server Components — is the architecture that eliminates flicker and layout shift from client-side feature flags.
The client-side alternative: fetch flag values after JavaScript loads, causing a render with the default behaviour, then a re-render with the flag-determined behaviour. Users see a flash of the wrong content.
The edge alternative: Middleware assigns the flag value before the page renders, passes it in a cookie or header, and the Server Component renders the correct version on the first paint.
Pattern: Middleware assigns, Server Components read
ts
tsx
The variant is assigned at the edge (Middleware), persisted in a cookie for consistency across sessions, and passed to the Server Component via a request header. No flicker, no client-side fetch, no A/B testing library needed for this use case.
GrowthBook, LaunchDarkly, and Edge-Compatible SDKs
The pattern above works for simple feature flags you own. For large flag systems, dedicated services provide management UIs, targeting rules, and analytics. The architectural question is whether they're edge-compatible.
GrowthBook — open source, has a lightweight edge SDK that works in V8 isolates. Works in Next.js Middleware.
LaunchDarkly — their "Edge SDK" works in Vercel Edge Middleware. The standard Node.js SDK does not work at the edge.
Statsig — edge-native, designed for Middleware use cases.
The general pattern for using any flag service at the edge:
ts
The critical guard: always set a timeout on the flag service fetch. If the flag service is slow or unavailable, your Middleware must not block the request indefinitely. Fall back to the default variant, log the error, and continue.
Personalisation Architecture — Balancing Edge and Origin
The edge is fast but limited. Origin is slower but can run anything. The architecture that maximises personalisation with minimal latency:
text
The edge layer handles the cheap, fast decisions. The origin handles the expensive, data-dependent decisions. The client never needs to make a separate request for personalisation data — it arrived with the HTML.
Declarative Proxying with rewrites()
For the common case of forwarding a whole path prefix to an external service with no per-request logic (no auth header injection, no conditional branching), rewrites() in next.config.ts is the real declarative option — it's evaluated before Middleware, with lower overhead than a Route Handler:
ts
The moment you need to attach a secret header, transform the request/response body, or branch on request data, that logic doesn't fit in rewrites() — that's when you write it as a Route Handler instead (F-7 covers the manual proxy pattern in full).
Where We Go From Here
A-10 scales the architecture to multi-zone and multi-tenant deployments — running multiple Next.js applications on the same domain, routing between them at the edge, and the patterns for building SaaS platforms where each tenant needs isolation. With A-9's edge routing knowledge, A-10 shows how to compose multiple applications into one.
Prisma and the Edge Runtime Are Incompatible
This is a constraint that every team discovers by accident, never by documentation. You learn about Prisma in P-5. You learn about the Edge runtime here. At some point, you put them together. It fails at runtime with an error that is confusing unless you already know what caused it:
text
Or, depending on the specific Prisma version and what Node.js API was hit first:
Error: The `net` module is not available in Edge Runtime.
Why: The Edge runtime is not Node.js. It's a V8 isolate — the same JavaScript engine but without Node.js's standard library. Prisma uses Node.js APIs internally: net for TCP connections, tls for TLS, dns for name resolution. None of these exist in the Edge runtime. Prisma cannot open a database connection from an Edge function.
This affects:
Middleware (middleware.ts) — always runs in the Edge runtime on Vercel
Route Handlers with export const runtime = 'edge'
Any file imported into those entry points that transitively imports Prisma
The second failure mode is subtle: even if your Middleware file doesn't import Prisma directly, if it imports a utility from lib/auth.ts that imports from lib/db.ts which imports Prisma, the entire chain fails.
What Actually Works at the Edge
Data access pattern
Edge runtime compatible?
Prisma Client
❌
Drizzle ORM + node-postgres
❌ (uses Node.js net)
Drizzle ORM + @neondatabase/serverless
✅
Prisma Accelerate (HTTP proxy)
✅
Upstash Redis (HTTP-based)
✅
Vercel KV / Edge Config
✅
Plain fetch() to an HTTP API
✅
@vercel/postgres (serverless driver)
✅
The pattern for edge-compatible database access is HTTP over a WebSocket-compatible protocol — not raw TCP. That's why @neondatabase/serverless works: it tunnels the PostgreSQL wire protocol over WebSockets or HTTP, which the Edge runtime supports.
The Fix: Drizzle + Neon Serverless at the Edge
bash
ts
ts
The Fix: Prisma Accelerate
If your codebase is deep in Prisma and you can't switch to Drizzle, Prisma Accelerate is an HTTP proxy that lets Prisma work in edge environments:
bash
ts
The DATABASE_URL must point to the Prisma Accelerate endpoint, not directly to your database. Accelerate handles the TCP connection server-side; the Edge function communicates with Accelerate over HTTP.
Prisma Accelerate has pricing. At low scale it's fine. At high Middleware traffic (millions of requests), the cost adds up — benchmark it against the Drizzle + Neon serverless approach.
The Architectural Decision Matrix
The real question is whether you should be hitting the database from the Edge at all:
Use case
Right approach
Auth token validation (JWT)
Verify JWT signature in Middleware — no database needed
Session lookup by session ID
Redis (Upstash) — O(1) lookup, no database round-trip
Feature flag check
Edge Config / Redis — pre-computed flags, no database
User role check for routing
Put role in JWT claims — read from token, no database
Actual data access
Move to Origin — Server Component or Route Handler
The insight: most "I need database access in Middleware" use cases can be solved without a database. JWT claims, Redis lookups, and Edge Config cover the vast majority. Reserve the database for origin functions where the full Node.js ecosystem is available.
The rule of thumb: Middleware should authenticate, authorise, and route. It should not fetch business data. If you find yourself writing complex data queries in Middleware, the architecture is wrong — move that logic to the origin.
Knowledge Check
Why is it critical to set a timeout when fetching feature flags from a service like GrowthBook in Next.js Middleware?
Why does Prisma Client fail when used directly in Next.js Middleware (middleware.ts)?
What is the recommended approach for handling complex data queries and business logic that require database access?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
// middleware.tsimport{ NextRequest, NextResponse }from'next/server';import{ geolocation }from'@vercel/functions';import{ auth }from'@/lib/auth';// Auth.js edge-compatible versionexportconst config ={ matcher:['/((?!api|_next/static|_next/image|favicon.ico).*)'],};exportasyncfunctionmiddleware(request: NextRequest){// The response object starts as a pass-throughconst response = NextResponse.next();// 1. Auth checkconst session =awaitauth();const isProtected = request.nextUrl.pathname.startsWith('/dashboard');if(isProtected &&!session){const loginUrl =newURL('/login', request.url); loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname);return NextResponse.redirect(loginUrl);}// 2. Geo-routing — NextRequest.geo was removed in Next.js 15; use @vercel/functions insteadconst country =geolocation(request).country ??'US'; response.headers.set('x-user-country', country);// 3. Security headers response.headers.set('X-Frame-Options','DENY'); response.headers.set('X-Content-Type-Options','nosniff');return response;}
import{ geolocation }from'@vercel/functions';exportasyncfunctionmiddleware(request: NextRequest){const country =geolocation(request).country;// Redirect EU users to the EU version of the siteconst euCountries =newSet(['DE','FR','IT','ES','NL','BE','AT','PL']);if(country && euCountries.has(country)){const euUrl =newURL(request.nextUrl.href); euUrl.hostname ='eu.example.com';return NextResponse.redirect(euUrl);}// Rewrite for localised content without changing the URLif(country ==='JP'){const rewriteUrl =newURL(`/jp${request.nextUrl.pathname}`, request.url);return NextResponse.rewrite(rewriteUrl);}return NextResponse.next();}
// middleware.tsimport{ NextRequest, NextResponse }from'next/server';// Simple deterministic assignment using user IDfunctionassignVariant(userId:string):'control'|'treatment'{// Stable hash — same user always gets same variantconst hash = userId.charCodeAt(0)%2;return hash ===0?'control':'treatment';}exportasyncfunctionmiddleware(request: NextRequest){const response = NextResponse.next();let experimentCookie = request.cookies.get('experiment-new-checkout')?.value;if(!experimentCookie){const userId = request.cookies.get('user-id')?.value ?? crypto.randomUUID(); experimentCookie =assignVariant(userId); response.cookies.set('experiment-new-checkout', experimentCookie,{ httpOnly:true, sameSite:'lax', maxAge:60*60*24*30,// 30 days});}// Pass variant to Server Components via header response.headers.set('x-experiment-new-checkout', experimentCookie);return response;}