#nextjs /
Next.js 13 Middleware in Practice: Authentication, i18n, and A/B Testing
An in-depth exploration of advanced Next.js 13 middleware usage for implementing authentication, internationalization, and A/B testing scenarios.
Goal
Next.js 13 Middleware is a powerful feature that allows you to execute code before a request reaches a page or API route. This article explores advanced middleware usage patterns through practical examples, demonstrating how to implement authentication, internationalization (i18n), and A/B testing -- three of the most common real-world scenarios.
Background
Advantages of Middleware
- Edge execution: Runs on the Edge Runtime with extremely low latency.
- Request interception: Can execute before any page loads.
- Flexible control: Can modify request headers, redirect, and rewrite URLs.
- Serverless: No server management required; automatically deployed to edge nodes.
Limitations of Middleware
// Things middleware cannot do
// 1. Cannot access Node.js APIs (e.g., fs, path)
// 2. Cannot use most npm packages
// 3. Cannot interact with databases directly
// 4. Cannot execute long-running tasks
// middleware.js
export function middleware(request) {
// Web APIs are available
const url = new URL(request.url)
// Node.js APIs are NOT available
// const fs = require('fs') // Error
// Edge Runtime supported APIs work fine
const response = NextResponse.next()
return response
}
Basic Configuration
Creating Middleware
// middleware.js (must be placed in the project root)
import { NextResponse } from 'next/server'
export function middleware(request) {
// Get the request URL
const url = new URL(request.url)
// Get request headers
const headers = new Headers(request.headers)
// Add custom response headers
headers.set('x-custom-header', 'custom-value')
// Create a response
const response = NextResponse.next({
request: {
headers
}
})
// Set a cookie
response.cookies.set('theme', 'dark', {
path: '/',
httpOnly: true,
secure: true
})
return response
}
// Configure which paths the middleware matches
export const config = {
matcher: [
// Match all paths
'/((?!api|_next/static|_next/image|favicon.ico).*)',
// Or use more precise matching
'/dashboard/:path*',
'/admin/:path*'
]
}
Middleware Chain
// middleware.js
import { NextResponse } from 'next/server'
// Auth middleware function
function withAuth(handler) {
return (request) => {
const token = request.cookies.get('token')
if (!token) {
const url = new URL('/login', request.url)
url.searchParams.set('redirect', request.url)
return NextResponse.redirect(url)
}
return handler(request)
}
}
// Logging middleware function
function withLogging(handler) {
return (request) => {
console.log(`${request.method} ${request.url}`)
return handler(request)
}
}
// Compose middlewares
export const middleware = withLogging(
withAuth((request) => {
return NextResponse.next()
})
)
Authentication Implementation
JWT Authentication
// middleware.js
import { NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
// Verify JWT token
async function verifyToken(token) {
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
const { payload } = await jwtVerify(token, secret)
return payload
} catch (error) {
return null
}
}
// Public paths (no authentication required)
const publicPaths = [
'/',
'/login',
'/register',
'/forgot-password',
'/api/auth/login',
'/api/auth/register'
]
// Static asset paths
const staticPaths = [
'/_next/static',
'/_next/image',
'/favicon.ico',
'/images'
]
export async function middleware(request) {
const { pathname } = request.nextUrl
// Allow static assets through directly
if (staticPaths.some(path => pathname.startsWith(path))) {
return NextResponse.next()
}
// Allow public paths through directly
if (publicPaths.includes(pathname)) {
return NextResponse.next()
}
// Get the token
const token = request.cookies.get('token')?.value
// Not logged in, redirect to login page
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
// Verify the token
const payload = await verifyToken(token)
if (!payload) {
// Invalid token, clear cookie and redirect
const response = NextResponse.redirect(new URL('/login', request.url))
response.cookies.delete('token')
return response
}
// Check user role
const userRole = payload.role
const requiredRole = getRequiredRole(pathname)
if (requiredRole && userRole !== requiredRole) {
return NextResponse.redirect(new URL('/unauthorized', request.url))
}
// Add user info to request headers
const headers = new Headers(request.headers)
headers.set('x-user-id', payload.userId)
headers.set('x-user-role', userRole)
return NextResponse.next({
request: { headers }
})
}
// Get the required role based on path
function getRequiredRole(pathname) {
if (pathname.startsWith('/admin')) return 'admin'
if (pathname.startsWith('/manager')) return 'manager'
return null
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
}
Session Authentication
// middleware.js
import { NextResponse } from 'next/server'
import { getIronSession } from 'iron-session'
// Session configuration
const sessionOptions = {
password: process.env.SESSION_SECRET,
cookieName: 'session',
cookieOptions: {
secure: process.env.NODE_ENV === 'production'
}
}
export async function middleware(request) {
const response = NextResponse.next()
// Get the session
const session = await getIronSession(request, response, sessionOptions)
// Check if the user is logged in
if (!session.user) {
// Not logged in, redirect to login page
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', request.url)
return NextResponse.redirect(loginUrl)
}
// Check if the session has expired
if (session.expiresAt && Date.now() > session.expiresAt) {
// Session expired, destroy it and redirect
session.destroy()
return NextResponse.redirect(new URL('/login', request.url))
}
// Renew the session
session.expiresAt = Date.now() + 24 * 60 * 60 * 1000 // 24 hours
await session.save()
return response
}
Internationalization (i18n)
Language Detection and Routing
// middleware.js
import { NextResponse } from 'next/server'
// Supported languages
const locales = ['en', 'zh', 'ja', 'ko']
const defaultLocale = 'en'
// Get the user's language preference
function getLocale(request) {
// 1. Check URL parameter
const url = new URL(request.url)
const urlLocale = url.searchParams.get('lang')
if (urlLocale && locales.includes(urlLocale)) {
return urlLocale
}
// 2. Check cookie
const cookieLocale = request.cookies.get('locale')?.value
if (cookieLocale && locales.includes(cookieLocale)) {
return cookieLocale
}
// 3. Check Accept-Language header
const acceptLanguage = request.headers.get('Accept-Language')
if (acceptLanguage) {
const preferredLocale = acceptLanguage
.split(',')
.map(lang => lang.split(';')[0].split('-')[0])
.find(lang => locales.includes(lang))
if (preferredLocale) {
return preferredLocale
}
}
// 4. Fall back to default language
return defaultLocale
}
export function middleware(request) {
const { pathname } = request.nextUrl
// Check if the path already contains a locale prefix
const pathnameHasLocale = locales.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
if (pathnameHasLocale) {
// Already has a locale prefix, proceed directly
const locale = pathname.split('/')[1]
const response = NextResponse.next()
response.cookies.set('locale', locale)
return response
}
// Detect user language
const locale = getLocale(request)
// Redirect to the path with a locale prefix
const newUrl = new URL(`/${locale}${pathname}`, request.url)
const response = NextResponse.redirect(newUrl)
response.cookies.set('locale', locale)
return response
}
export const config = {
matcher: [
// Match all paths, but exclude API routes, static assets, etc.
'/((?!api|_next/static|_next/image|favicon.ico|public).*)'
]
}
Translation File Loading
// middleware.js
import { NextResponse } from 'next/server'
// Translation mapping
const translations = {
en: {
home: 'Home',
about: 'About',
contact: 'Contact'
},
zh: {
home: '首页',
about: '关于',
contact: '联系我们'
}
}
export function middleware(request) {
const response = NextResponse.next()
// Get current language
const locale = request.cookies.get('locale')?.value || 'en'
// Add translation data to response headers
response.headers.set('x-locale', locale)
response.headers.set('x-translations', JSON.stringify(translations[locale] || translations.en))
return response
}
A/B Testing
Implementing A/B Testing
// middleware.js
import { NextResponse } from 'next/server'
// Experiment configuration
const experiments = {
'new-checkout': {
variants: ['control', 'variant-a', 'variant-b'],
weights: [50, 25, 25] // Traffic distribution
},
'homepage-design': {
variants: ['control', 'new-design'],
weights: [70, 30]
}
}
// Get user group assignment
function getUserGroup(userId, experimentName) {
const experiment = experiments[experimentName]
if (!experiment) return 'control'
// Use deterministic hashing for grouping
const hash = hashString(`${userId}-${experimentName}`)
const bucket = hash % 100
let cumulative = 0
for (let i = 0; i < experiment.variants.length; i++) {
cumulative += experiment.weights[i]
if (bucket < cumulative) {
return experiment.variants[i]
}
}
return experiment.variants[0]
}
// Simple hash function
function hashString(str) {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash
}
return Math.abs(hash)
}
export function middleware(request) {
const response = NextResponse.next()
// Get user ID (can come from cookie or session)
const userId = request.cookies.get('user-id')?.value ||
request.headers.get('x-user-id') ||
'anonymous'
// Assign groups for each experiment
const groups = {}
for (const experimentName of Object.keys(experiments)) {
groups[experimentName] = getUserGroup(userId, experimentName)
}
// Add group information to response headers
response.headers.set('x-experiment-groups', JSON.stringify(groups))
// Set cookie for client-side access
response.cookies.set('experiment-groups', JSON.stringify(groups), {
path: '/',
httpOnly: false, // Client needs to access this
secure: process.env.NODE_ENV === 'production'
})
return response
}
Client-Side Usage
// hooks/useExperiment.js
import { useState, useEffect } from 'react'
export function useExperiment(experimentName) {
const [variant, setVariant] = useState('control')
useEffect(() => {
// Get experiment group from cookie
const groups = document.cookie
.split('; ')
.find(row => row.startsWith('experiment-groups='))
?.split('=')[1]
if (groups) {
try {
const parsed = JSON.parse(decodeURIComponent(groups))
setVariant(parsed[experimentName] || 'control')
} catch (error) {
console.error('Failed to parse experiment groups:', error)
}
}
}, [experimentName])
return variant
}
// Usage
function CheckoutPage() {
const variant = useExperiment('new-checkout')
return (
<div>
{variant === 'control' && <OriginalCheckout />}
{variant === 'variant-a' && <NewCheckoutVariantA />}
{variant === 'variant-b' && <NewCheckoutVariantB />}
</div>
)
}
Rewrites and Redirects
URL Rewriting
// middleware.js
import { NextResponse } from 'next/server'
export function middleware(request) {
const { pathname } = request.nextUrl
// API versioning
if (pathname.startsWith('/api/v1/')) {
// Rewrite to v1 API
const url = request.nextUrl.clone()
url.pathname = pathname.replace('/api/v1/', '/api/')
return NextResponse.rewrite(url)
}
// Redirect old paths to new paths
const redirects = {
'/old-blog': '/blog',
'/old-about': '/about',
'/old-contact': '/contact'
}
if (redirects[pathname]) {
return NextResponse.redirect(new URL(redirects[pathname], request.url))
}
// A/B test route rewriting
const experimentGroups = request.cookies.get('experiment-groups')?.value
if (experimentGroups) {
try {
const groups = JSON.parse(experimentGroups)
if (groups['homepage-design'] === 'new-design') {
const url = request.nextUrl.clone()
url.pathname = '/new-homepage'
return NextResponse.rewrite(url)
}
} catch (error) {
// Parse failed, use default route
}
}
return NextResponse.next()
}
Geolocation-Based Redirects
// middleware.js
import { NextResponse } from 'next/server'
export function middleware(request) {
// Get geolocation (Edge Runtime supported)
const country = request.geo?.country || 'US'
// Redirect based on country
const countryRedirects = {
CN: '/zh',
JP: '/ja',
KR: '/ko'
}
const redirectPath = countryRedirects[country]
if (redirectPath && !request.nextUrl.pathname.startsWith(redirectPath)) {
const url = request.nextUrl.clone()
url.pathname = `${redirectPath}${request.nextUrl.pathname}`
return NextResponse.redirect(url)
}
return NextResponse.next()
}
Performance Optimization
Caching Strategies
// middleware.js
import { NextResponse } from 'next/server'
export function middleware(request) {
const response = NextResponse.next()
// Static asset caching
if (request.nextUrl.pathname.startsWith('/static')) {
response.headers.set(
'Cache-Control',
'public, max-age=31536000, immutable'
)
}
// API response caching
if (request.nextUrl.pathname.startsWith('/api/public')) {
response.headers.set(
'Cache-Control',
'public, max-age=3600, s-maxage=3600'
)
}
return response
}
Summary
Next.js 13 Middleware provides powerful request handling capabilities for frontend development:
- Authentication control: JWT, Session, and role-based access.
- Internationalization: Language detection and route rewriting.
- A/B testing: User grouping and experiment tracking.
- URL handling: Rewrites, redirects, and versioning.
- Performance optimization: Caching strategies and geolocation.
Mastering middleware usage makes your Next.js application more flexible, secure, and efficient.