Chaining or Combining Next.js Middleware
UPDATE: 2024-11-29: We're not using next-auth. We've written our own auth subsystem and corresponding middleware. There's a pretty good example here in the next-sass-starter template , from which you could extract the auth function shown there into your own withAuth
middleware function.
UPDATE: 2024-11-23: We've updated NextResponse.rewrite
correctly passing the request object to NextResponse.rewrite
ensuring that any header values, cookies, etc., will be preserved across rewrites and all chained middleware. chainMiddleware.ts
and all withMiddleware
functions and types have also been updated.
Original Post
In Next.js - you're only allowed one middleware.ts
file in the root of your project. Sadly, most examples, including third-party solutions for Next.js like next-auth, assume there is only one middleware function, 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 .
Jasser Mark Arioste and his repo here was the closest we found to the ideal solution, which we've adapted as follows...
First - the chainMiddleware.ts
file...
// https://reacthustle.com/blog/how-to-chain-multiple-middleware-functions-in-nextjs// https://github.com/jmarioste/next-middleware-guide/
import { NextResponse } from 'next/server'import { ChainableMiddleware, MiddlewareFactory as MiddlewareFactory } from './@types'
/** * Helper to compose multiple MiddlewareFactory instances together. * * We restrict the type of middleware to ChainableMiddleware, which is a strict * subset of the full NextMiddleware type. Specifically, we require middleware * layers to return Promise<NextResponse>, as opposed to a few other return * types that NextMiddleware allows in general. This restriction allows * middleware layers that want to set response cookies to do so using the * NextResponse cookies api: each layer reliably receives a NextResponse from * the next layer in the chain, and can add cookies to it before passing it on * down. * * Important: layers must construct NextResponses (specifically the .next() and * .rewrite() variants) by passing in the (possibly mutated) request object. * Then any headers/cookies that have been set on the request object (including * by earlier layers) will be properly passed on to the Next.js request * handlers: page components, server actions and route handlers. */export function chainMiddleware( functions: MiddlewareFactory[] = [], index = 0): ChainableMiddleware { const current = functions[index] if (current) { const next = chainMiddleware(functions, index + 1) return current(next) }
return async (request) => NextResponse.next({ request })}
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, NextRequest } from 'next/server'
import { MiddlewareFactory } from './@types'
export const withNonce: MiddlewareFactory = (next) => { return async (request: NextRequest, event: NextFetchEvent) => { const nonce = Buffer.from(crypto.randomUUID()).toString('base64') request.headers.set('x-nonce', nonce) return next(request, event) }}
withCSP.ts
// https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy
import { NextFetchEvent, NextRequest } from 'next/server'
import { MiddlewareFactory } from './@types'import { getConfig, getCSPHeader } from '@/config'
export const withCSP: MiddlewareFactory = (next) => { const config = getConfig() return async (request: NextRequest, event: NextFetchEvent) => { if (config.cspEnabled && request.headers.get('x-nonce') != null) { const nonce = request.headers.get('x-nonce') const cspHeader = getCSPHeader(nonce) // TODO: Should this be set on the request? request.headers.set('Content-Security-Policy', cspHeader) const response = await next(request, event) response.headers.set('Content-Security-Policy', cspHeader) return response } else { return next(request, event) } }}
withPrefersColorScheme.ts
import { NextFetchEvent, NextMiddleware, NextRequest, NextResponse } from 'next/server'
import { MiddlewareFactory } from './@types'
export const withPrefersColorScheme: MiddlewareFactory = (next) => { return async (request: NextRequest, event: NextFetchEvent) => { const response = await next(request, event) 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, NextRequest, NextResponse } from 'next/server'
import { i18nConfig } from '@/i18n/i18n-config'import { getLocale } from './get-locale'
import type { MiddlewareFactory } from '../@types'
export const withI18n: MiddlewareFactory = (next) => { return async (request: NextRequest, event: NextFetchEvent) => { const pathname = request.nextUrl.pathname const locale = getLocale(request) const localeInPath = i18nConfig.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 === i18nConfig.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), { request }) } 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 it matches // the detected locale from getLocale above. if (localeInPath !== locale) { // There's a mismatch let path: string if (locale === i18nConfig.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, event) } } }}
and last but not least....
withAuth.ts
// https://github.com/nextauthjs/next-auth/discussions/8961
import NextAuth from 'next-auth'import { NextFetchEvent, 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) => { return async (request: NextRequest, event: 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, event) }}
.. which is at the heart of the discussion here
Also for completeness, our MiddlewareFactory type
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server'
export type ChainableMiddleware = ( request: NextRequest, event: NextFetchEvent) => Promise<NextResponse>
export type MiddlewareFactory = (middleware: ChainableMiddleware) => ChainableMiddleware
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.