Introductie: De Onzichtbare Bewaker van Je Applicatie
In eerdere artikelen hebben we uitgebreid stilgestaan bij data ophalen met Server Components en data muteren met Server Actions. Maar er is een cruciale laag die we nog niet hebben besproken — eentje die eerlijk gezegd vaak over het hoofd wordt gezien: wat er gebeurt vóórdat een request je pagina of API-route bereikt.
Dat is precies waar middleware om de hoek komt kijken.
Middleware in Next.js is code die tussen het inkomende request en je applicatie draait. Het onderschept elk request, onderzoekt het, en kan beslissen om het door te laten, om te leiden, te herschrijven, of headers toe te voegen — nog voordat je page of route handler wordt uitgevoerd. Je kunt het vergelijken met een portier die bij de ingang van een gebouw controleert of bezoekers welkom zijn. Niet glamoureus, maar absoluut essentieel.
Met de overgang naar de App Router is middleware alleen maar belangrijker geworden. Het draait op de Edge Runtime, wat betekent dat het razendsnel is en dicht bij de gebruiker wordt uitgevoerd. Maar juist die snelheid brengt beperkingen met zich mee: je hebt geen toegang tot Node.js API's, geen directe databaseverbindingen, en geen zware bewerkingen. Dat is geen bug — middleware is bewust lichtgewicht ontworpen.
In dit artikel (het derde deel in onze serie over de Next.js App Router) nemen we je mee door alles wat je moet weten over middleware: van de basisopzet en route matching tot authenticatiepatronen, beveiligingsheaders, internationalisering, en de best practices die je in productie nodig hebt. Dus, laten we erin duiken.
De Basis: Middleware Opzetten
Middleware configureer je door een middleware.ts (of .js) bestand aan te maken in de root van je project — op hetzelfde niveau als je app-directory, of in de src-map als je die gebruikt. Belangrijk: er is precies één middleware-bestand per project. Niet meer, niet minder.
Het Simpelste Voorbeeld
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Elke request passeert hier
console.log("Request naar:", request.nextUrl.pathname);
// Laat het request gewoon door
return NextResponse.next();
}
De middleware-functie ontvangt een NextRequest-object (een uitbreiding van de standaard Web Request API) en retourneert een NextResponse. Je hebt drie basisacties tot je beschikking:
NextResponse.next()— het request doorlaten naar de volgende handler.NextResponse.redirect(url)— de gebruiker omleiden naar een andere URL.NextResponse.rewrite(url)— het request intern herschrijven naar een andere URL (de gebruiker ziet de originele URL in de browser).
Route Matching met de Config Matcher
Standaard draait middleware op elk request — inclusief statische bestanden, afbeeldingen en favicon. Dat is bijna nooit wat je wilt. Gelukkig kun je met de config.matcher middleware beperken tot specifieke routes:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Draait alleen op routes die matchen
return NextResponse.next();
}
// Middleware draait ALLEEN op deze routes
export const config = {
matcher: [
"/dashboard/:path*",
"/api/:path*",
"/profiel/:path*",
],
};
De matcher ondersteunt padpatronen met wildcards. Het :path* gedeelte matcht nul of meer padsegmenten. Dus /dashboard/:path* matcht zowel /dashboard als /dashboard/instellingen als /dashboard/gebruikers/123.
Een veelgebruikt patroon — en eerlijk gezegd het patroon dat ik zelf ook het meest gebruik — is om statische assets uit te sluiten met een negatieve regex:
export const config = {
matcher: [
// Match alle routes behalve statische bestanden en afbeeldingen
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
Dit is de aanpak die de meeste productie-applicaties gebruiken. Je middleware draait dan op alle pagina's en API-routes, maar wordt overgeslagen voor statische assets. Efficiënt en overzichtelijk.
Conditionele Logica op Basis van het Pad
Soms wil je binnen één middleware-functie verschillende logica toepassen voor verschillende routes. Dat kan prima met conditionele checks op het pad:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// API-routes: voeg CORS-headers toe
if (pathname.startsWith("/api/")) {
return handleCors(request);
}
// Dashboard-routes: controleer authenticatie
if (pathname.startsWith("/dashboard")) {
return handleAuth(request);
}
// Alle andere routes: voeg beveiligingsheaders toe
return addSecurityHeaders(request);
}
Dit patroon werkt prima voor een handvol routes, maar kan behoorlijk onoverzichtelijk worden bij complexere applicaties. Verderop in dit artikel bespreken we hoe je middleware kunt structureren voor grotere projecten.
Authenticatie in Middleware: De Juiste Aanpak
Authenticatie is verreweg de meest voorkomende use case voor middleware. Maar hier is het cruciaal om de juiste strategie te kiezen. De verkeerde aanpak kan leiden tot beveiligingslekken, of op z'n minst prestatieproblemen waar je pas in productie achter komt.
Het Principe: Defense in Depth
De gouden regel: vertrouw nooit uitsluitend op middleware voor authenticatie. Middleware is de eerste verdedigingslinie, niet de laatste. Implementeer verificatie op elk niveau waar data wordt benaderd:
- Middleware — snelle, optimistische check (is er een sessiecookie aanwezig?).
- Server Components / Route Handlers — volledige sessievalidatie (is de sessie geldig? heeft de gebruiker de juiste rechten?).
- Data Access Layer — autorisatiecontrole bij elke database-query.
Waarom al die lagen? Omdat middleware op de Edge draait en beperkte mogelijkheden heeft. Je kunt er geen volledige JWT-validatie doen met een database-lookup, en je wilt er ook geen zware cryptografische bewerkingen uitvoeren. Middleware is bedoeld voor snelle, lichtgewicht checks die de meeste ongeautoriseerde requests al bij de deur tegenhouden.
Eenvoudige Cookie-Based Authenticatie
Het simpelste authenticatiepatroon in middleware controleert alleen of er een sessiecookie aanwezig is:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const beschermdeRoutes = ["/dashboard", "/profiel", "/instellingen"];
const authRoutes = ["/inloggen", "/registreren"];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const sessieToken = request.cookies.get("session-token")?.value;
// Beschermde routes: redirect naar inlogpagina als er geen sessie is
const isBeschermd = beschermdeRoutes.some((route) =>
pathname.startsWith(route)
);
if (isBeschermd && !sessieToken) {
const inlogUrl = new URL("/inloggen", request.url);
inlogUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(inlogUrl);
}
// Auth-routes: redirect naar dashboard als gebruiker al is ingelogd
const isAuthRoute = authRoutes.some((route) =>
pathname.startsWith(route)
);
if (isAuthRoute && sessieToken) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/profiel/:path*", "/instellingen/:path*", "/inloggen", "/registreren"],
};
Let op: deze middleware valideert de sessie niet. Het controleert alleen of er een cookie aanwezig is. De daadwerkelijke validatie gebeurt in je Server Component of route handler. Dit is een bewuste ontwerpkeuze — het voorkomt dat ongeautoriseerde gebruikers de pagina überhaupt zien laden, terwijl de echte beveiligingslogica draait op een plek waar je volledige Node.js-mogelijkheden tot je beschikking hebt.
Integratie met Auth.js (NextAuth v5)
Auth.js v5 (voorheen NextAuth) is de meest gebruikte authenticatiebibliotheek voor Next.js. De integratie met middleware is gestroomlijnd, maar vereist wel aandacht voor de juiste opzet.
Eerst configureer je Auth.js in een centraal bestand:
// auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
Credentials({
credentials: {
email: { label: "Email", type: "email" },
wachtwoord: { label: "Wachtwoord", type: "password" },
},
authorize: async (credentials) => {
// Valideer credentials tegen je database
const gebruiker = await vindGebruiker(credentials);
return gebruiker ?? null;
},
}),
],
pages: {
signIn: "/inloggen",
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isIngelogd = !!auth?.user;
const isBeschermd = nextUrl.pathname.startsWith("/dashboard");
if (isBeschermd && !isIngelogd) {
return false; // Redirect naar inlogpagina
}
return true;
},
},
});
Vervolgens gebruik je de auth-export in je middleware:
// middleware.ts
export { auth as middleware } from "./auth";
export const config = {
matcher: ["/dashboard/:path*", "/profiel/:path*"],
};
Auth.js handelt het zware werk af: het controleert de sessiecookie, decodeert het JWT-token (als je de JWT-strategie gebruikt), en roept de authorized-callback aan om te bepalen of het request mag doorgaan. Door de auth-functie direct als middleware te exporteren, heb je een krachtige authenticatielaag in slechts twee regels code. Best indrukwekkend, toch?
Wil je extra logica toevoegen naast de Auth.js-controle? Dan wrap je de auth-functie:
// middleware.ts
import { auth } from "./auth";
import { NextResponse } from "next/server";
export default auth((request) => {
const { pathname } = request.nextUrl;
const isIngelogd = !!request.auth;
// Admin-routes: extra rolcontrole
if (pathname.startsWith("/admin") && request.auth?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/niet-geautoriseerd", request.url));
}
// Reguliere beschermde routes
if (!isIngelogd && pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/inloggen", request.url));
}
return NextResponse.next();
});
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*", "/profiel/:path*"],
};
Sessievalidatie in Server Components
Na de middleware-check valideer je de sessie opnieuw in je Server Component — dit is de echte beveiligingslaag:
// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const sessie = await auth();
if (!sessie?.user) {
redirect("/inloggen");
}
return (
Welkom, {sessie.user.name}
{/* Dashboard-inhoud */}
);
}
Deze dubbele controle — eerst in middleware, dan in de Server Component — is het defense-in-depth principe in de praktijk. De middleware vangt het gros van de ongeautoriseerde requests op voor een snelle gebruikerservaring, terwijl de Server Component de definitieve beveiligingsgarantie biedt. Ik weet het, het voelt als dubbel werk. Maar geloof me: het is de moeite waard.
Beveiligingsheaders Instellen via Middleware
Een andere belangrijke taak voor middleware is het toevoegen van HTTP beveiligingsheaders. Deze headers beschermen je applicatie tegen veelvoorkomende aanvallen zoals XSS, clickjacking en data-sniffing.
Content Security Policy (CSP) met Nonces
Content Security Policy is een van de krachtigste beveiligingsmechanismen voor webapplicaties. Het bepaalt welke bronnen (scripts, stijlen, afbeeldingen, enzovoort) de browser mag laden. De meest veilige aanpak gebruikt nonces — unieke tokens die per request worden gegenereerd:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Genereer een unieke nonce voor dit request
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
// Bouw de CSP-header
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`;
// Verwijder newlines en extra spaties
const schoneCsp = cspHeader.replace(/\s{2,}/g, " ").trim();
// Stel headers in op het request (voor Server Components)
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
// Stel headers in op het response
const response = NextResponse.next({
request: { headers: requestHeaders },
});
response.headers.set("Content-Security-Policy", schoneCsp);
return response;
}
De nonce wordt via de request-headers doorgegeven aan je Server Components, waar je het kunt gebruiken in je <script>- en <style>-tags. Alleen scripts en stijlen met de juiste nonce worden door de browser uitgevoerd — alles zonder nonce wordt simpelweg geblokkeerd.
In je root layout haal je de nonce op uit de headers:
// app/layout.tsx
import { headers } from "next/headers";
import Script from "next/script";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const headersList = await headers();
const nonce = headersList.get("x-nonce") ?? "";
return (
{children}
);
}
Uitgebreide Beveiligingsheaders
Naast CSP zijn er meer headers die je eigenlijk standaard zou moeten instellen in elke productie-applicatie. Het kost weinig moeite en het maakt je app een stuk veiliger:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
function voegBeveiligingsHeadersToe(response: NextResponse): NextResponse {
// Voorkom MIME-type sniffing
response.headers.set("X-Content-Type-Options", "nosniff");
// Voorkom clickjacking
response.headers.set("X-Frame-Options", "DENY");
// Beperk de Referrer-informatie
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
// Schakel browser-specifieke beveiligingsfeatures in
response.headers.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), interest-cohort=()"
);
// Forceer HTTPS
response.headers.set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload"
);
// Voorkom DNS-prefetching naar externe domeinen
response.headers.set("X-DNS-Prefetch-Control", "on");
return response;
}
export function middleware(request: NextRequest) {
const response = NextResponse.next();
return voegBeveiligingsHeadersToe(response);
}
Laten we kort doorlopen wat elke header doet:
- X-Content-Type-Options: nosniff — voorkomt dat de browser bestanden als een ander MIME-type interpreteert dan opgegeven.
- X-Frame-Options: DENY — voorkomt dat je site in een iframe kan worden geladen (bescherming tegen clickjacking).
- Referrer-Policy — bepaalt hoeveel referrer-informatie wordt meegestuurd bij navigatie.
- Permissions-Policy — beperkt welke browser-API's je site mag gebruiken.
- Strict-Transport-Security — dwingt browsers om altijd HTTPS te gebruiken.
Dit hele pakket is eerlijk gezegd een no-brainer. Kopieer het, plak het in je middleware, en je app is meteen een stuk beter beschermd.
CORS Configureren voor API-Routes
Als je API-routes hebt die door externe domeinen worden aangesproken, moet je CORS (Cross-Origin Resource Sharing) correct configureren. Middleware is hiervoor een uitstekende plek, omdat je het centraal regelt voor al je API-routes in plaats van het per route te moeten doen.
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const toegestaneDomeinen = [
"https://mijn-frontend.nl",
"https://staging.mijn-frontend.nl",
];
function handleCors(request: NextRequest): NextResponse {
const origin = request.headers.get("origin") ?? "";
const isToegestaan = toegestaneDomeinen.includes(origin);
// Preflight OPTIONS-request afhandelen
if (request.method === "OPTIONS") {
const response = new NextResponse(null, { status: 204 });
if (isToegestaan) {
response.headers.set("Access-Control-Allow-Origin", origin);
}
response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS"
);
response.headers.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization"
);
response.headers.set("Access-Control-Max-Age", "86400");
return response;
}
// Regulier request: voeg CORS-headers toe
const response = NextResponse.next();
if (isToegestaan) {
response.headers.set("Access-Control-Allow-Origin", origin);
}
response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS"
);
return response;
}
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith("/api/")) {
return handleCors(request);
}
return NextResponse.next();
}
Belangrijk: gebruik nooit een wildcard (*) voor Access-Control-Allow-Origin in productie, tenzij je API echt volledig publiek is. Ik heb het te vaak zien misgaan bij projecten waar een wildcard "tijdelijk" werd ingesteld en vervolgens nooit meer werd aangepast. Controleer altijd expliciet of de origin in je lijst met toegestane domeinen staat.
Internationalisering (i18n) met Middleware
Middleware is dé plek om taalroutering te implementeren. Je kunt de gewenste taal detecteren op basis van cookies, de Accept-Language-header, of het URL-pad, en de gebruiker vervolgens naar de juiste versie omleiden.
Handmatige Locale-Detectie
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { match as matchLocale } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";
const locales = ["nl", "en", "de", "fr"];
const standaardLocale = "nl";
function detecteerLocale(request: NextRequest): string {
// 1. Check of er een locale-cookie is
const cookieLocale = request.cookies.get("NEXT_LOCALE")?.value;
if (cookieLocale && locales.includes(cookieLocale)) {
return cookieLocale;
}
// 2. Check de Accept-Language header
const negotiatorHeaders: Record = {};
request.headers.forEach((value, key) => {
negotiatorHeaders[key] = value;
});
const talen = new Negotiator({ headers: negotiatorHeaders }).languages();
try {
return matchLocale(talen, locales, standaardLocale);
} catch {
return standaardLocale;
}
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check of het pad al een locale bevat
const padHeeftLocale = locales.some(
(locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (padHeeftLocale) {
return NextResponse.next();
}
// Detecteer de gewenste locale en redirect
const locale = detecteerLocale(request);
const nieuweUrl = new URL(`/${locale}${pathname}`, request.url);
return NextResponse.redirect(nieuweUrl);
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
Integratie met next-intl
Voor productie-applicaties raad ik aan om een bibliotheek als next-intl te gebruiken. Het biedt een robuuste middleware-integratie die locale-detectie, cookie-opslag en routing voor je afhandelt, zodat je het wiel niet opnieuw hoeft uit te vinden:
// middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
// i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["nl", "en", "de", "fr"],
defaultLocale: "nl",
localePrefix: "as-needed", // Geen prefix voor de standaardtaal
});
Met localePrefix: "as-needed" wordt de standaardtaal (hier Nederlands) zonder prefix weergegeven (/over-ons), terwijl andere talen wel een prefix krijgen (/en/about-us). Dit is in mijn ervaring de meest gebruiksvriendelijke configuratie.
Middleware Combineren: Authenticatie + i18n + Beveiliging
In een echte productie-applicatie heb je meestal meerdere middleware-concerns tegelijk. En hier wordt het interessant: Next.js ondersteunt slechts één middleware-bestand, dus je moet al die logica combineren. Hier is een bewezen patroon dat ik in meerdere projecten heb gebruikt:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
const intlMiddleware = createIntlMiddleware(routing);
const beschermdeRoutes = ["/dashboard", "/profiel", "/instellingen"];
function isBeschermdRoute(pathname: string): boolean {
// Verwijder locale-prefix voor de check
const schoonPad = pathname.replace(/^\/(nl|en|de|fr)/, "") || "/";
return beschermdeRoutes.some((route) => schoonPad.startsWith(route));
}
function voegBeveiligingsHeadersToe(response: NextResponse): NextResponse {
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set(
"Referrer-Policy",
"strict-origin-when-cross-origin"
);
response.headers.set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains"
);
return response;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Stap 1: Controleer authenticatie voor beschermde routes
if (isBeschermdRoute(pathname)) {
const sessieToken = request.cookies.get("session-token")?.value;
if (!sessieToken) {
const inlogUrl = new URL("/inloggen", request.url);
inlogUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(inlogUrl);
}
}
// Stap 2: Verwerk internationalisering
const response = intlMiddleware(request);
// Stap 3: Voeg beveiligingsheaders toe aan het response
voegBeveiligingsHeadersToe(response);
return response;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
De volgorde is hier cruciaal: authenticatie eerst (zodat ongeautoriseerde requests direct worden omgeleid zonder onnodig werk), dan internationalisering (die het response-object aanmaakt), en tot slot beveiligingsheaders (die aan het response worden toegevoegd). Door deze volgorde voorkom je onnodig werk voor requests die toch worden afgewezen.
Rate Limiting op de Edge
Middleware draait op de Edge, dicht bij de gebruiker. Dat maakt het een uitstekende plek voor eenvoudige rate limiting. Maar let op: voor robuuste rate limiting in productie wil je een externe oplossing als Upstash Redis of Vercel KV gebruiken, omdat in-memory opslag niet werkt over meerdere edge-locaties heen.
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10 s"), // 10 requests per 10 seconden
analytics: true,
prefix: "middleware",
});
export async function middleware(request: NextRequest) {
// Alleen API-routes rate-limiten
if (!request.nextUrl.pathname.startsWith("/api/")) {
return NextResponse.next();
}
// Gebruik IP-adres als identifier
const ip = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
const { success, limit, remaining, reset } = await ratelimit.limit(ip);
if (!success) {
return new NextResponse("Te veel requests. Probeer het later opnieuw.", {
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
"Retry-After": Math.ceil((reset - Date.now()) / 1000).toString(),
},
});
}
const response = NextResponse.next();
response.headers.set("X-RateLimit-Limit", limit.toString());
response.headers.set("X-RateLimit-Remaining", remaining.toString());
return response;
}
Upstash Redis is edge-compatibel en werkt via HTTP, waardoor het naadloos samenwerkt met Next.js middleware. De slidingWindow-strategie is in de meeste gevallen de beste keuze: het voorkomt burst-traffic terwijl het eerlijker is dan een vaste window.
Geo-Routing en A/B Testing
De Edge Runtime geeft je toegang tot geografische informatie via request.geo. Dit opent interessante mogelijkheden voor geo-specifieke routing en A/B-testing — twee use cases waar middleware echt uitblinkt.
Geo-Routing
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const land = request.geo?.country ?? "NL";
const { pathname } = request.nextUrl;
// Redirect EU-gebruikers naar de GDPR-compliant versie
const euLanden = ["NL", "BE", "DE", "FR", "IT", "ES", "AT", "SE"];
if (pathname === "/privacy" && euLanden.includes(land)) {
return NextResponse.rewrite(new URL("/privacy/eu", request.url));
}
// Stel het land beschikbaar als header voor Server Components
const response = NextResponse.next();
response.headers.set("x-user-country", land);
return response;
}
A/B Testing met Cookies
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname !== "/") {
return NextResponse.next();
}
// Check of de gebruiker al in een testgroep zit
const testGroep = request.cookies.get("ab-test-home")?.value;
if (testGroep) {
// Rewrite naar de juiste variant
return NextResponse.rewrite(
new URL(`/home/${testGroep}`, request.url)
);
}
// Nieuwe gebruiker: wijs random een groep toe
const nieuweGroep = Math.random() < 0.5 ? "variant-a" : "variant-b";
const response = NextResponse.rewrite(
new URL(`/home/${nieuweGroep}`, request.url)
);
// Sla de groep op in een cookie (30 dagen)
response.cookies.set("ab-test-home", nieuweGroep, {
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: "lax",
});
return response;
}
Merk op dat we hier NextResponse.rewrite() gebruiken in plaats van redirect(). Een rewrite verandert de URL die de server verwerkt, maar de gebruiker ziet nog steeds de originele URL in de browser. Perfect voor A/B-testen — de gebruiker merkt helemaal niet dat er een experiment loopt.
Veelgemaakte Fouten en Best Practices
Nu we alle patronen hebben doorgenomen, laten we het even hebben over de valkuilen. Want eerlijk? Bijna iedereen trapt er minstens één keer in.
Fout 1: Zware Logica in Middleware
Middleware draait op elk gematch request. Als je er zware bewerkingen in plaatst — database-queries, externe API-calls, complexe berekeningen — wordt elk request langzamer. De vuistregel: middleware moet binnen een paar milliseconden klaar zijn.
// SLECHT: database-query in middleware
export async function middleware(request: NextRequest) {
const sessie = await db.sessions.findUnique({
where: { token: request.cookies.get("token")?.value },
include: { user: { include: { permissions: true } } },
});
// ...
}
// GOED: alleen cookie-controle in middleware
export function middleware(request: NextRequest) {
const token = request.cookies.get("session-token")?.value;
if (!token) {
return NextResponse.redirect(new URL("/inloggen", request.url));
}
return NextResponse.next();
}
Fout 2: Geen Matcher Configureren
Zonder matcher draait je middleware op elk request, inclusief _next/static, afbeeldingen en fonts. Dat is niet alleen onnodig, het kan ook tot echt onverwacht gedrag leiden. Configureer altijd een matcher of sluit statische assets expliciet uit.
Fout 3: Node.js API's Gebruiken
Middleware draait op de Edge Runtime, niet op Node.js. Dat betekent dat je geen toegang hebt tot fs, path, crypto (gedeeltelijk), of andere Node.js-specifieke modules. Gebruik edge-compatibele alternatieven:
- In plaats van
jsonwebtoken→ gebruikjosevoor JWT-operaties. - In plaats van
crypto.createHash()→ gebruik de Web Crypto API (crypto.subtle). - In plaats van directe database-drivers → gebruik HTTP-gebaseerde clients (bijv. Upstash Redis, Neon HTTP).
Fout 4: Middleware als Enige Beveiligingslaag
We hebben het al benadrukt, maar het kan niet vaak genoeg worden gezegd: middleware is een optimistische beveiligingslaag. Het is niet de plek voor je enige verdedigingslinie. Valideer altijd de sessie opnieuw in je Server Components, route handlers en data access layer. Echt, altijd.
Best Practices Samengevat
- Houd middleware lichtgewicht — geen database-queries, geen zware berekeningen.
- Configureer altijd een matcher — sluit statische assets uit.
- Gebruik edge-compatibele bibliotheken — controleer of je dependencies de Edge Runtime ondersteunen.
- Implementeer defense in depth — middleware is de eerste laag, niet de enige.
- Test middleware lokaal — gebruik
next devom middleware-gedrag te testen voordat je deployt. - Log met mate — elke
console.login middleware wordt bij elk request uitgevoerd. Dat tikt aan. - Gebruik
rewritein plaats vanredirectwaar mogelijk — rewrites zijn sneller omdat ze geen extra round-trip naar de browser vereisen. - Overweeg de volgorde — bij gecombineerde middleware, voer de snelste en meest eliminerende checks eerst uit.
Debugging en Monitoring
Middleware kan best lastig zijn om te debuggen, omdat het op de Edge draait en niet dezelfde debugging-tools beschikbaar heeft als een gewone Node.js-omgeving. Hier zijn een paar technieken die ik zelf regelmatig gebruik.
Logging met Headers
Een handige debug-techniek is om informatie als headers aan het response toe te voegen. Zo kun je in de browser's DevTools precies zien wat er in middleware is gebeurd:
// middleware.ts — alleen in development
export function middleware(request: NextRequest) {
const response = NextResponse.next();
if (process.env.NODE_ENV === "development") {
response.headers.set("x-middleware-matched", "true");
response.headers.set("x-middleware-pathname", request.nextUrl.pathname);
response.headers.set(
"x-middleware-timestamp",
Date.now().toString()
);
}
return response;
}
Middleware Testen
Je kunt middleware ook gewoon unit-testen door de NextRequest-klasse te mocken:
// __tests__/middleware.test.ts
import { middleware } from "../middleware";
import { NextRequest } from "next/server";
describe("Middleware", () => {
it("redirect ongeauthenticeerde gebruikers naar inlogpagina", () => {
const request = new NextRequest(
new URL("/dashboard", "http://localhost:3000")
);
const response = middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/inloggen");
});
it("laat geauthenticeerde gebruikers door naar het dashboard", () => {
const request = new NextRequest(
new URL("/dashboard", "http://localhost:3000"),
{
headers: {
cookie: "session-token=geldige-token-waarde",
},
}
);
const response = middleware(request);
expect(response.status).toBe(200);
});
});
Conclusie: Middleware als Strategische Laag
Middleware in Next.js is geen bijzaak — het is een strategische laag die de ruggengraat vormt van authenticatie, beveiliging, internationalisering en verkeersmanagement in je applicatie. Door op de Edge te draaien, is het razendsnel en wordt het dicht bij je gebruikers uitgevoerd, ongeacht waar ter wereld ze zich bevinden.
Hier zijn de belangrijkste takeaways:
- Middleware draait vóór je pagina's en API-routes — gebruik het voor snelle, lichtgewicht checks.
- Authenticatie in middleware is optimistisch — valideer altijd opnieuw in Server Components.
- Beveiligingsheaders en CSP kun je centraal instellen via middleware.
- Internationalisering en geo-routing zijn ideale use cases voor de Edge.
- Houd middleware snel: geen database-queries, geen zware logica.
- Combineer meerdere concerns in de juiste volgorde: authenticatie → routing → headers.
Met de kennis uit dit artikel, samen met de eerdere artikelen over data ophalen en Server Actions, heb je nu een compleet beeld van de drie pijlers van de Next.js App Router: data lezen, data schrijven, en requests intercepteren. En dat is eerlijk gezegd een behoorlijk solide basis om robuuste, veilige en performante full-stack applicaties te bouwen.