Chaining or Combining Next.js Middleware

Submitted on Jul 14, 2024, 3:42 p.m.

In Next.js - you're only allowed one middleware.ts file in the root of your project, and so if you want to modularize your middleware strategy, you'll need to decide how best to wrap, combine, or chain middleware functions.

We needed to do exactly this at our agency - Infonomic while working on a client project that needed to combine both i18n and auth middleware.

There's isn't a lot of documentation on the topic, although there are a couple of blog posts, as well as a discussion here. Mateusz Janota also has an interesting project here. There's very little guidance in the latest version of NextAuth.js (Auth.js) (scroll down just a little to see their section on middleware.ts) which on its own might be worth raising an issue in their Github repo.

Thanks to Jasser Mark Arioste and his repo here - we've pretty much followed his approach, and created the following....

First - the chainMiddleware.ts file...

import { NextMiddleware, NextResponse } from 'next/server'
import { MiddlewareFactory } from './@types'
export function chainMiddleware(functions: MiddlewareFactory[] = [], index = 0): NextMiddleware {
const current = functions[index]
if (current) {
const next = chainMiddleware(functions, index + 1)
return current(next)
}
return () => NextResponse.next()
}

And then in middleware.ts we're able to combine all of our middleware plugins below as follows...

export default chainMiddleware([withNonce, withCSP, withPrefersColorScheme, withAuth, withI18n])

Here's what our 'plugins' look like...

withNonce.ts

import { NextFetchEvent, NextMiddleware, NextRequest, NextResponse } from 'next/server'
import { MiddlewareFactory } from './@types'
export const withNonce: MiddlewareFactory = (next: NextMiddleware) => {
return async (request: NextRequest, _next: NextFetchEvent) => {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
request.headers.set('x-nonce', nonce)
return next(request, _next)
}
}

withCSP.ts

// https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy
import { NextFetchEvent, NextMiddleware, NextRequest, NextResponse } from 'next/server'
import { MiddlewareFactory } from './@types'
export const withCSP: MiddlewareFactory = (next: NextMiddleware) => {
return async (request: NextRequest, _next: NextFetchEvent) => {
if (process.env.CSP_ENABLED === 'true' && request.headers.get('x-nonce') != null) {
const nonce = request.headers.get('x-nonce')
const cspHeader = `
default-src 'self';
connect-src 'self' *.google.com *.gstatic.com *.recaptcha.net;
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
script-src-elem 'self' 'nonce-${nonce}' *.recaptcha.net;
style-src 'self' 'unsafe-inline';
frame-src 'self' *.google.com *.recaptcha.net www.youtube-nocookie.com *.vimeo.com;
img-src 'self' blob: data: cdn.yourdomain.org;
media-src 'self' blob: data: cdn.yourdomain.org;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
block-all-mixed-content;
upgrade-insecure-requests;
`
// Replace newline characters and spaces
const contentSecurityPolicyHeaderValue = cspHeader.replace(/\s{2,}/g, ' ').trim()
request.headers.set('Content-Security-Policy', contentSecurityPolicyHeaderValue)
const response = await next(request, _next)
if (response) {
response.headers.set('Content-Security-Policy', contentSecurityPolicyHeaderValue)
}
return response
} else {
return next(request, _next)
}
}
}

withPrefersColorScheme.ts

import { NextFetchEvent, NextMiddleware, NextRequest, NextResponse } from 'next/server'
import { MiddlewareFactory } from './@types'
export const withPrefersColorScheme: MiddlewareFactory = (next: NextMiddleware) => {
return async (request: NextRequest, _next: NextFetchEvent) => {
const response = await next(request, _next)
if (response) {
response.headers.set('Accept-CH', 'Sec-CH-Prefers-Color-Scheme')
response.headers.set('Vary', 'Sec-CH-Prefers-Color-Scheme')
response.headers.set('Critical-CH', 'Sec-CH-Prefers-Color-Scheme')
}
return response
}
}

withI18n

import { NextFetchEvent, NextMiddleware, NextRequest, NextResponse } from 'next/server'
import { defaultLocale, locales } from '@/i18n/settings'
import { getLocale } from './get-locale'
import type { MiddlewareFactory } from '../@types'
export const withI18n: MiddlewareFactory = (next: NextMiddleware) => {
return async (request: NextRequest, _next: NextFetchEvent) => {
const pathname = request.nextUrl.pathname
const locale = getLocale(request)
const localeInPath = locales.find((locale) => {
return pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
})
// Locale is NOT in the path
if (localeInPath == null) {
// Used for either a rewrite, or redirect in the case of the default
// language. Also ensure that any query string values are preserved
// via request.nextUrl.search
let path = `/${locale}${pathname.startsWith('/') ? '' : '/'}${pathname}`
if (request.nextUrl?.search != null) {
path += request.nextUrl.search
}
if (locale === defaultLocale) {
// Default language - so leave it out of the visible browser URL,
// but rewrite for Next.js locale params support.
return NextResponse.rewrite(new URL(path, request.url))
} else {
// NOT the default language - so redirect with the new 'locale'
// containing URL.
return NextResponse.redirect(new URL(path, request.url))
}
} else {
// We have a locale in the URL, so check to see if it's the correct
// locale after setLanguageAction and lng cookie has been set via...
// src/i18n/set-language-action.ts
if (localeInPath !== locale) {
// There's a mismatch
let path: string
if (locale === defaultLocale) {
path = pathname.includes(`/${localeInPath}/`)
? pathname.replace(`/${localeInPath}`, '')
: pathname.replace(`/${localeInPath}`, '/')
if (request.nextUrl?.search != null) {
path += request.nextUrl.search
}
} else {
path = pathname.includes(`/${localeInPath}/`)
? pathname.replace(`/${localeInPath}`, locale)
: pathname.replace(`/${localeInPath}`, `/${locale}`)
if (request.nextUrl?.search != null) {
path += request.nextUrl.search
}
}
return NextResponse.redirect(new URL(path, request.url))
} else {
return next(request, _next)
}
}
}
}

and last but not least....

withAuth.ts

// https://github.com/nextauthjs/next-auth/discussions/8961
import NextAuth from 'next-auth'
import { NextFetchEvent, NextMiddleware, NextRequest, NextResponse } from 'next/server'
import { defaultLocale } from '@/i18n/settings'
import { authConfig } from '../../auth.config'
const { auth } = NextAuth(authConfig)
import { getLocale } from './withI18n/get-locale'
import type { MiddlewareFactory } from './@types'
function containsDashboard(value: string) {
const regex = /^(\/[a-zA-Z]{2})?\/dashboard(\/.*)?$/
return regex.test(value)
}
export const withAuth: MiddlewareFactory = (next: NextMiddleware) => {
return async (request: NextRequest, _next: NextFetchEvent) => {
const { nextUrl } = request
const locale = getLocale(request)
const session = await auth()
console.log('withAuth', session)
if (session == null && containsDashboard(nextUrl.pathname)) {
const url =
locale !== defaultLocale
? `/${locale}/sign-in?callbackUrl=${encodeURIComponent(nextUrl.pathname)}`
: `/sign-in?callbackUrl=${encodeURIComponent(nextUrl.pathname)}`
return NextResponse.redirect(new URL(url, request.url))
}
if (session != null && containsDashboard(nextUrl.pathname) === false) {
const url = locale !== defaultLocale ? `/${locale}/dashboard` : `/dashboard`
return NextResponse.redirect(new URL(url, request.url))
}
return next(request, _next)
}
}

.. which is at the heart of the discussion here

Also for completeness, our MiddlewareFactory type

import { NextMiddleware } from 'next/server'
export type MiddlewareFactory = (middleware: NextMiddleware) => NextMiddleware

And our getLocale.ts helper function...

import { NextRequest } from 'next/server'
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
import { defaultLocale, locales, cookieName } from '@/i18n/settings'
import { localeFromPath } from '@/i18n/utils'
/**
* Current detection strategy is 1) cookie, 2) path, 3) user agent, 4) default
* @param request
* @returns string locale
*/
export function getLocale(request: NextRequest): string {
let locale
// Cookie detection first
if (request.cookies.has(cookieName)) {
locale = request?.cookies?.get(cookieName)?.value
// Double check that the cookie value is actually a valid
// locale (it may have been 'fiddled' with)
if (locale != null && locales.includes(locale) === false) {
locale = undefined
}
}
// Path detection second
if (locale == null) {
const pathname = request.nextUrl.pathname
locale = localeFromPath(pathname, false)
}
// Browser / user agent locales 3rd
if (locale == null) {
// NOTE: @formatjs/intl-localematcher will fail with RangeError: Incorrect locale information provided
// of there is no locale information in the request (for example when benchmarking the application)
try {
// Negotiator expects plain object so we need to transform headers
const negotiatorHeaders: Record<string, string> = {}
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value))
let browserLanguages = new Negotiator({ headers: negotiatorHeaders }).languages()
locale = match(browserLanguages, locales, defaultLocale)
} catch (error) {
// console.warn(`Failed to match locale: ${error}`)
locale = defaultLocale
}
}
// Lastly - fallback to default locale
if (locale == null) {
locale = defaultLocale
}
return locale
}

It would be great if Auth.js in particular could address this in their docs, as well as ideally change the signature and return type in the optional middleware function they offer, so that other middleware functions could 'plug into' this with a NextMiddlewareResult return type - like....

(alias) type NextMiddleware = (request: NextRequest, event: NextFetchEvent) => NextMiddlewareResult | Promise<NextMiddlewareResult>

Hope some of this helps...

T.