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 - Edge Route Handlers — individual Route Handlers opted into
runtime = 'edge'
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// middleware.ts import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; // Auth.js edge-compatible version export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], }; export async function middleware(request: NextRequest) { // The response object starts as a pass-through const response = NextResponse.next(); // 1. Auth check const session = await auth(); const isProtected = request.nextUrl.pathname.startsWith('/dashboard'); if (isProtected && !session) { const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname); return NextResponse.redirect(loginUrl); } // 2. Geo-routing const country = request.geo?.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; }
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. The request.geo object contains:
country— ISO 3166-1 alpha-2 country code (e.g.,'US','DE','JP')region— region/state code (e.g.,'CA'for California)city— city namelatitude/longitude— approximate coordinates
tsexport async function middleware(request: NextRequest) { const country = request.geo?.country; // Redirect EU users to the EU version of the site const euCountries = new Set(['DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT', 'PL']); if (country && euCountries.has(country)) { const euUrl = new URL(request.nextUrl.href); euUrl.hostname = 'eu.example.com'; return NextResponse.redirect(euUrl); } // Rewrite for localised content without changing the URL if (country === 'JP') { const rewriteUrl = new URL(`/jp${request.nextUrl.pathname}`, request.url); return NextResponse.rewrite(rewriteUrl); } return NextResponse.next(); }
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// middleware.ts import { NextRequest, NextResponse } from 'next/server'; // Simple deterministic assignment using user ID function assignVariant(userId: string): 'control' | 'treatment' { // Stable hash — same user always gets same variant const hash = userId.charCodeAt(0) % 2; return hash === 0 ? 'control' : 'treatment'; } export async function middleware(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; }
tsx// app/checkout/page.tsx import { headers } from 'next/headers'; export default async function CheckoutPage() { const headersList = await headers(); const variant = headersList.get('x-experiment-new-checkout') ?? 'control'; if (variant === 'treatment') { return <NewCheckoutFlow />; } return <CurrentCheckoutFlow />; }
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// middleware.ts import { GrowthBook } from '@growthbook/growthbook'; export async function middleware(request: NextRequest) { const gb = new GrowthBook({ apiHost: 'https://cdn.growthbook.io', clientKey: process.env.GROWTHBOOK_CLIENT_KEY!, attributes: { id: request.cookies.get('user-id')?.value, country: request.geo?.country, }, }); await gb.loadFeatures({ timeout: 500 }); // timeout prevents slow flag service from blocking page const variant = gb.getFeatureValue('new-checkout', 'control'); const response = NextResponse.next(); response.headers.set('x-experiment-checkout', variant); return response; }
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:
Request
→ Middleware (edge, ~1ms)
→ Auth check → redirect if needed
→ Geo-routing → rewrite path
→ Feature flag assignment → cookie + header
→ Returns: rewritten/redirected URL + headers/cookies
→ Next.js server (origin, ~50-150ms)
→ Server Components read cookies and headers
→ Database queries with personalised parameters
→ Rendered HTML with correct content
→ Client
→ Hydrates with personalised state already present
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.
proxy.js — Declarative External API Proxying
Next.js 15 introduced proxy.js (or proxy.ts) as a file convention for proxying external APIs without writing Route Handlers:
ts// proxy.js export default { '/api/external/search': { target: 'https://search.example.com', changeOrigin: true, rewrite: (path) => path.replace('/api/external/search', '/v2/search'), headers: { 'X-API-Key': process.env.SEARCH_API_KEY, }, }, '/api/external/products': { target: 'https://products.example.com', changeOrigin: true, }, };
This replaces the common pattern of Route Handlers that exist purely to proxy a request to a third-party API (adding auth headers, changing the origin). The proxy configuration is evaluated at the Middleware layer — lower overhead than a full Route Handler.
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-4. 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:
Error: PrismaClient is unable to be run in the Edge Runtime.
As an alternative, try Accelerate: https://pris.ly/d/accelerate
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
bashnpm install drizzle-orm @neondatabase/serverless
ts// lib/db-edge.ts — edge-compatible database client import { neon } from '@neondatabase/serverless' import { drizzle } from 'drizzle-orm/neon-http' import * as schema from './schema' // neon() returns an HTTP-based SQL function, not a TCP connection const sql = neon(process.env.DATABASE_URL!) export const db = drizzle(sql, { schema })
ts// middleware.ts — uses edge-compatible db import { db } from '@/lib/db-edge' // NOT lib/db (Prisma) import { users } from '@/lib/schema' import { eq } from 'drizzle-orm' export async function middleware(req: NextRequest) { const userId = req.cookies.get('userId')?.value if (!userId) return NextResponse.redirect(new URL('/login', req.url)) // This works in Edge — HTTP-based query, no TCP socket const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1) if (!user) return NextResponse.redirect(new URL('/login', req.url)) return NextResponse.next() }
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:
bashnpm install @prisma/extension-accelerate
ts// lib/db-edge.ts import { PrismaClient } from '@prisma/client/edge' import { withAccelerate } from '@prisma/extension-accelerate' export const prisma = new PrismaClient().$extends(withAccelerate())
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.