Middleware in Next.js App Router: Authenticatie, Beveiliging en Routing

Ontdek hoe je middleware in de Next.js App Router inzet voor authenticatie, beveiligingsheaders, internationalisering en rate limiting. Met praktische codevoorbeelden en best practices voor productie.

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:

  1. Middleware — snelle, optimistische check (is er een sessiecookie aanwezig?).
  2. Server Components / Route Handlers — volledige sessievalidatie (is de sessie geldig? heeft de gebruiker de juiste rechten?).
  3. 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}