DEV Community

Cover image for Implementing Multiple Middleware in Next.js: Combining NextAuth V(5) and Internationalization
Tanzim Hossain
Tanzim Hossain

Posted on • Edited on • Originally published at dev.to

1

Implementing Multiple Middleware in Next.js: Combining NextAuth V(5) and Internationalization

Have you ever wondered how to use multiple middleware functions in Next.js when it only exports one function? In this tutorial, I'll guide you through implementing a modular middleware architecture that combines authentication with NextAuth v5 and internationalization using Next-Intl.

By the end of this article, you'll have a clean, maintainable approach to chaining middleware that you can easily extend for additional functionality.

Let's dive in! 🚀

The Challenge

Next.js middleware is incredibly powerful, but there's a limitation: you can only export one middleware function. This creates a challenge when you need multiple middleware capabilities like:

  • Authentication checks
  • Language detection and routing
  • Logging or analytics
  • Rate limiting

How do we solve this elegantly? By creating a middleware chain that preserves modularity while meeting Next.js requirements.

Our Approach

We'll create:

  1. A utility to chain multiple middleware functions
  2. Individual middleware modules for authentication and internationalization
  3. A clean way to combine them in the correct order

File Structure

First, let's understand our project organization:

/my-next-app
|-- /src
|   |-- /middlewares
|   |   |-- chain.ts
|   |   |-- withI18nMiddleware.ts
|   |   `-- withAuthMiddleware.ts
|   |-- auth.config.ts
|   |-- auth.ts
|   |-- middleware.ts
|   |-- i18n.config.ts
`-- next.config.js
Enter fullscreen mode Exit fullscreen mode

Step 1: Creating the Chain Utility

The chain utility is the foundation of our solution. It takes an array of middleware functions and returns a single function that executes them in sequence.

// middlewares/chain.ts
import { NextMiddlewareResult } from 'next/dist/server/web/types';
import { NextResponse } from 'next/server';
import type { NextFetchEvent, NextRequest } from 'next/server';

export type CustomMiddleware = (
  request: NextRequest,
  event: NextFetchEvent,
  response: NextResponse
) => NextMiddlewareResult | Promise<NextMiddlewareResult>;

type MiddlewareFactory = (middleware: CustomMiddleware) => CustomMiddleware;

export function chain(
  functions: MiddlewareFactory[],
  index = 0
): CustomMiddleware {
  const current = functions[index];

  if (current) {
    const next = chain(functions, index + 1);
    return current(next);
  }

  return (request: NextRequest, event: NextFetchEvent, response: NextResponse) => {
    return response;
  };
}
Enter fullscreen mode Exit fullscreen mode

This recursive implementation allows each middleware to decide whether to continue the chain or terminate early by returning a response.

Step 2: Internationalization Middleware

Next, let's implement a middleware that handles language detection and routing:

// middlewares/withI18nMiddleware.ts
import { NextResponse } from 'next/server';
import type { NextFetchEvent, NextRequest } from 'next/server';
import { i18n } from '@/i18n.config';
import { match as matchLocale } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
import { CustomMiddleware } from './chain';

function getLocale(request: NextRequest): string | undefined {
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  // @ts-ignore locales are readonly
  const locales: string[] = i18n.locales;
  const languages = new Negotiator({ headers: negotiatorHeaders }).languages();

  const locale = matchLocale(languages, locales, i18n.defaultLocale);
  return locale;
}

export function withI18nMiddleware(middleware: CustomMiddleware) {
  return async (
    request: NextRequest,
    event: NextFetchEvent,
    response: NextResponse
  ) => {
    const pathname = request.nextUrl.pathname;
    const pathnameIsMissingLocale = i18n.locales.every(
      locale => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
    );

    if (pathnameIsMissingLocale) {
      const locale = getLocale(request)
      const redirectURL = new URL(request.url)
      if (locale) {
        redirectURL.pathname = `/${locale}${pathname.startsWith('/') ? '' : '/'}${pathname}`
      }

      // Preserve query parameters
      redirectURL.search = request.nextUrl.search

      return NextResponse.redirect(redirectURL.toString())
    }

    return middleware(request, event, response);
  };
}
Enter fullscreen mode Exit fullscreen mode

This middleware detects the user's preferred language and ensures URLs include the appropriate locale prefix.

Step 3: Authentication Middleware

Now for our authentication middleware using NextAuth v5:

// middlewares/withAuthMiddleware.ts
import { getToken } from 'next-auth/jwt';
import { NextResponse } from 'next/server';
import type { NextFetchEvent, NextRequest } from 'next/server';
import { CustomMiddleware } from './chain';

export function withAuthMiddleware(middleware: CustomMiddleware): CustomMiddleware {
  return async (request: NextRequest, event: NextFetchEvent, response: NextResponse) => {
    const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });

    if (!token) {
      return NextResponse.redirect(new URL('/api/auth/signin', request.url));
    }

    return middleware(request, event, response);
  };
}
Enter fullscreen mode Exit fullscreen mode

This middleware checks for a valid authentication token and redirects unauthenticated users to the sign-in page.

Step 4: Combining Everything

Finally, let's put it all together in our main middleware file:

// middleware.ts
import { chain } from '@/middlewares/chain';
import { withI18nMiddleware } from '@/middlewares/withI18nMiddleware';
import { withAuthMiddleware } from '@/middlewares/withAuthMiddleware';

export default chain([withI18nMiddleware, withAuthMiddleware]);

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico|images).*)'],
};
Enter fullscreen mode Exit fullscreen mode

Note the order matters! Here, internationalization runs first, followed by authentication. This ensures we handle locale routing before authentication checks.

The Benefits of This Approach

Our middleware chain architecture provides several advantages:

  • Modularity: Each middleware focuses on a single responsibility
  • Extensibility: Add new middleware by creating a module and adding it to the chain
  • Maintainability: Logic is isolated, making debugging and updates easier
  • Reusability: Middleware can be reused across projects

Conclusion

With this architecture in place, you can easily combine multiple middleware functions in Next.js while maintaining clean, modular code. This approach scales beautifully as your application grows in complexity.

You could extend this further with middleware for:

  • Analytics and logging
  • Rate limiting
  • Feature flags
  • A/B testing

What other middleware would you add to your Next.js applications? Let me know in the comments!

Sentry image

Hands-on debugging session: instrument, monitor, and fix

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

RSVP here →

Top comments (0)

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more