Plainform

Mobile Sidebar

Mobile Sidebar

Next.js Middleware: Common SaaS Use Cases

2 Jun 2026
9 minute read
R
Ronald SolticzkiBackend Engineer
Next.js Middleware: Common SaaS Use Cases

Next.js middleware sits between the incoming request and the rest of your application. Before a page renders, before a database query runs, before any component tree is evaluated — middleware gets to inspect the request and decide what happens next.

For a SaaS product, that is extremely useful. You have routes that should redirect authenticated users. You have pages that should only load with a valid third-party session. You have access rules that apply across multiple routes and should not be scattered across individual page components.

This post covers the Next.js middleware use cases that actually come up when building a SaaS app, with real examples from Plainform's production setup.

How Next.js Middleware Works

Next.js middleware is a function exported from middleware.ts at the root of your project. It receives a NextRequest and returns a NextResponse — or nothing, which lets the request pass through.

import { NextRequest, NextResponse } from 'next/server';

export function middleware(req: NextRequest) {
  // inspect, redirect, rewrite, or pass through
  return NextResponse.next();
}

Controlling which routes run middleware

Without a matcher config, middleware runs on every request — including static files, fonts, images, and Next.js internals. That wastes resources and can cause unexpected behaviour.

The config export tells Next.js exactly which routes should trigger middleware:

export const config = {
  matcher: [
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    '/(api|trpc)(.*)',
  ],
};

This pattern skips Next.js internals and all static file extensions, but runs on every page and API route. Getting this right from the start saves a lot of debugging.

Using createRouteMatcher

For checking whether a request matches a specific set of routes, createRouteMatcher from @clerk/nextjs/server (also usable independently) compiles URL patterns into a fast matcher function:

import { createRouteMatcher } from '@clerk/nextjs/server';

const isAuthRoute = createRouteMatcher(['/sign-in(.*)', '/sign-up(.*)']);
const isDashboardRoute = createRouteMatcher(['/dashboard(.*)']);

You call it once at the module level and reuse it inside the middleware function on every request.

Use Case 1: Redirecting Signed-In Users Away from Auth Pages

One of the most common Next.js middleware use cases is preventing authenticated users from accessing pages they should not see — sign-in, sign-up, password reset.

Without this guard, a user who is already logged in can navigate to /sign-in and see the login form. That causes confusion and can lead to session conflicts if they try to sign in again with different credentials.

Implementation with Clerk

import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';

const isAuthRoute = createRouteMatcher([
  '/sign-in(.*)',
  '/sign-up(.*)',
  '/forgot-password(.*)',
  '/sso-callback(.*)',
]);

export default clerkMiddleware(async (auth, req) => {
  const { userId } = await auth();

  if (userId && isAuthRoute(req)) {
    const url = req.nextUrl.clone();
    url.pathname = '/';
    return NextResponse.redirect(url);
  }
});

clerkMiddleware wraps the standard middleware function and gives you access to the current session via auth(). If userId is set, the user is authenticated. If they are trying to hit an auth route, redirect them home.

Note the req.nextUrl.clone() before mutating the URL. The nextUrl object is read-only in some Next.js versions — cloning it first prevents runtime errors.

Why middleware and not page-level logic

You could handle this inside each auth page component with a redirect. But that means duplicating the logic across every auth page, and the redirect only happens after Next.js has already started processing the route.

Middleware makes the decision before routing starts. The component never runs. And the rule lives in one place.

Use Case 2: Protecting Routes That Require Authentication

The inverse case — redirecting unauthenticated users away from protected routes — is equally common. Dashboard pages, account settings, anything that requires a valid session.

const isProtectedRoute = createRouteMatcher([
  '/dashboard(.*)',
  '/settings(.*)',
  '/account(.*)',
]);

export default clerkMiddleware(async (auth, req) => {
  const { userId } = await auth();

  if (!userId && isProtectedRoute(req)) {
    const url = req.nextUrl.clone();
    url.pathname = '/sign-in';
    return NextResponse.redirect(url);
  }
});

This is the pattern most tutorials show. The value of doing it in middleware is that the protected page component never receives the request — there is nothing to accidentally leak.

Combining both rules

In practice, you will have both auth-route guards and protected-route guards in the same middleware function. The order matters — check the most critical condition first:

export default clerkMiddleware(async (auth, req) => {
  const { userId } = await auth();

  // Signed-in users should not see auth pages
  if (userId && isAuthRoute(req)) {
    const url = req.nextUrl.clone();
    url.pathname = '/';
    return NextResponse.redirect(url);
  }

  // Unauthenticated users cannot access protected routes
  if (!userId && isProtectedRoute(req)) {
    const url = req.nextUrl.clone();
    url.pathname = '/sign-in';
    return NextResponse.redirect(url);
  }
});

Use Case 3: Validating a Third-Party Session

This is where Next.js middleware really earns its place. Some routes should only be accessible when a valid session exists in a third-party system — not just when the user is authenticated in your app.

Protecting an order confirmation page

In Plainform, after a successful Stripe checkout, Stripe redirects the user to an order page with a session_id query parameter. Anyone who knows the URL format could try to access this page directly with a guessed or expired session ID.

The middleware validates the session against Stripe before the page loads:

import { stripe } from './lib/stripe/stripe';

const isOrderRoute = createRouteMatcher(['/order(.*)']);

export default clerkMiddleware(async (auth, req) => {
  const { userId } = await auth();

  if (userId && isAuthRoute(req)) {
    const url = req.nextUrl.clone();
    url.pathname = '/';
    return NextResponse.redirect(url);
  }

  if (isOrderRoute(req)) {
    const sessionId = req.nextUrl.searchParams.get('session_id');

    if (!sessionId) {
      const url = req.nextUrl.clone();
      url.pathname = '/';
      url.search = '';
      return NextResponse.redirect(url);
    }

    try {
      await stripe.checkout.sessions.retrieve(sessionId);
      return NextResponse.next();
    } catch (error) {
      console.error(error);
      const url = req.nextUrl.clone();
      url.pathname = '/';
      url.search = '';
      return NextResponse.redirect(url);
    }
  }
});

Three things happen here:

  • If there is no session_id in the query string, the user is redirected to the home page. url.search = '' clears the query string so users do not land on /? with empty params.
  • If the session_id is present, the middleware calls stripe.checkout.sessions.retrieve(sessionId). If the ID is invalid or expired, Stripe throws and the user is redirected.
  • If the session is valid, NextResponse.next() lets the request through to the page.

The page component never runs for invalid requests. No database queries fire. No layout renders.

A note on external API calls in middleware

Calling an external API like Stripe inside middleware is fine when scoped to the right routes. Plainform's matcher limits the Stripe call to /order(.*) only — a low-traffic route by nature. One Stripe API call per order page load is completely acceptable.

What you want to avoid is calling external APIs on every request. Keep expensive operations behind specific route matchers.

Use Case 4: Rewrites and Request Modification

Beyond redirects, Next.js middleware can rewrite requests — changing the destination URL without the browser knowing. This is useful for A/B testing, feature flags at the edge, or proxying third-party services.

Proxying an analytics service

Plainform proxies PostHog through a local path to avoid ad blockers and keep EU data in the EU. This is handled in next.config.mjs via rewrites rather than middleware, but the same pattern applies in middleware when you need request-level logic:

export function middleware(req: NextRequest) {
  if (req.nextUrl.pathname.startsWith('/relay-sdK3')) {
    const url = req.nextUrl.clone();
    url.hostname = 'eu.i.posthog.com';
    return NextResponse.rewrite(url);
  }
}

Injecting request headers

You can also add headers to the request before it reaches your route handler — useful for passing computed values like geolocation, tenant ID, or feature flag state:

export function middleware(req: NextRequest) {
  const requestHeaders = new Headers(req.headers);
  requestHeaders.set('x-pathname', req.nextUrl.pathname);

  return NextResponse.next({
    request: { headers: requestHeaders },
  });
}

The route handler or Server Component can then read x-pathname from the incoming headers.

Use Case 5: Role-Based Access Control

For SaaS products with multiple user roles — admin, member, viewer — middleware is a clean place to enforce role-based access at the route level.

const isAdminRoute = createRouteMatcher(['/admin(.*)']);

export default clerkMiddleware(async (auth, req) => {
  const { userId, has } = await auth();

  if (isAdminRoute(req)) {
    if (!userId) {
      const url = req.nextUrl.clone();
      url.pathname = '/sign-in';
      return NextResponse.redirect(url);
    }

    const isAdmin = has({ role: 'org:admin' });

    if (!isAdmin) {
      const url = req.nextUrl.clone();
      url.pathname = '/';
      return NextResponse.redirect(url);
    }
  }
});

The has helper from Clerk checks whether the current user has a specific role or permission. Unauthenticated users hit /sign-in. Authenticated users without the admin role hit /. Only users with org:admin get through.

The Full Plainform Middleware File

Here is the complete middleware from Plainform, combining the auth redirect and Stripe session validation use cases:

import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
import { stripe } from './lib/stripe/stripe';

const isProtectedBySignInStatus = createRouteMatcher([
  '/sign-in(.*)',
  '/sign-up(.*)',
  '/forgot-password(.*)',
  '/sso-callback(.*)',
]);

const isOrderRoute = createRouteMatcher(['/order(.*)']);

export default clerkMiddleware(async (auth, req) => {
  const { userId } = await auth();

  if (userId && isProtectedBySignInStatus(req)) {
    const url = req.nextUrl.clone();
    url.pathname = '/';
    return NextResponse.redirect(url);
  }

  if (isOrderRoute(req)) {
    const sessionId = req.nextUrl.searchParams.get('session_id');

    if (!sessionId) {
      const url = req.nextUrl.clone();
      url.pathname = '/';
      url.search = '';
      return NextResponse.redirect(url);
    }

    try {
      await stripe.checkout.sessions.retrieve(sessionId);
      return NextResponse.next();
    } catch (error) {
      console.error(error);
      const url = req.nextUrl.clone();
      url.pathname = '/';
      url.search = '';
      return NextResponse.redirect(url);
    }
  }
});

export const config = {
  matcher: [
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    '/(api|trpc)(.*)',
  ],
};

Two matchers. One function. Each use case is a self-contained condition block. Adding a new rule means adding a new createRouteMatcher and a new if block — the existing logic does not change.

Summary

Next.js middleware is small in size and large in leverage. The use cases that come up most often in a SaaS product:

  • Auth page redirects — send signed-in users away from sign-in and sign-up routes
  • Protected routes — block unauthenticated users from accessing private pages
  • Third-party session validation — verify an external session (Stripe, OAuth token) before the page loads
  • Request rewrites — proxy services, inject headers, or rewrite paths without the browser knowing
  • Role-based access — check permissions at the route level before any component runs

The key principle across all of them is the same: make the access decision as early as possible, keep the logic centralised, and let individual page components focus on rendering rather than access control.

Comments on this page

Leave comment

Stay up to date with our latest product updates. Unsubscribe anytime!

2026 © All rights reserved