Production-Grade Next.js: Authentication, Middleware & Advanced Configuration
Transition from prototype to production. This technical deep dive explores the infrastructure of robust Next.js applications. Learn to implement secure authentication flows with NextAuth.js, harness the power of Edge Middleware for dynamic routing, master build configurations, and implement internationalization (i18n) for global audiences.
Authentication
Authentication strategies
Next.js supports multiple authentication strategies including session-based, token-based (JWT), OAuth, and credentials-based authentication. The choice depends on your security requirements, scalability needs, and user experience goals—session-based is simpler but stateful, while token-based scales better for distributed systems.
Session-based auth
Session-based authentication stores user state on the server (in memory, database, or Redis) and sends a session ID cookie to the client. The server validates this ID on each request to identify the user.
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Browser │──login──▶│ Server │──store──▶│ Session │
│ │◀─cookie──│ │◀─lookup──│ Store │
└─────────┘ └─────────┘ └─────────┘
Token-based auth
Token-based authentication issues a self-contained token (usually JWT) that the client stores and sends with each request, eliminating server-side session storage and enabling stateless authentication perfect for APIs and microservices.
┌─────────┐ ┌─────────┐
│ Browser │──login──▶│ Server │
│ (store) │◀──JWT────│(verify) │
│ token │──JWT────▶│ only │
└─────────┘ └─────────┘
JWT with Next.js
JWTs in Next.js can be verified in API routes, middleware, or Server Components using libraries like jose (Edge-compatible) or jsonwebtoken, with tokens typically stored in httpOnly cookies for security.
// app/api/protected/route.ts import { jwtVerify } from 'jose'; export async function GET(request: Request) { const token = request.cookies.get('token')?.value; const secret = new TextEncoder().encode(process.env.JWT_SECRET); try { const { payload } = await jwtVerify(token!, secret); return Response.json({ user: payload }); } catch { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } }
NextAuth.js
NextAuth.js (now Auth.js) is the de-facto authentication library for Next.js, providing built-in support for 50+ OAuth providers, credentials, email/magic links, database adapters, and JWT/session management with minimal configuration.
// app/api/auth/[...nextauth]/route.ts import NextAuth from 'next-auth'; import GitHub from 'next-auth/providers/github'; const handler = NextAuth({ providers: [ GitHub({ clientId: process.env.GITHUB_ID!, clientSecret: process.env.GITHUB_SECRET! }) ], }); export { handler as GET, handler as POST };
OAuth providers
OAuth providers (Google, GitHub, Facebook, etc.) handle user authentication externally, returning an access token and user profile to your app—NextAuth.js simplifies this by abstracting the OAuth flow complexity.
// next-auth configuration providers: [ GoogleProvider({ clientId: process.env.GOOGLE_ID!, clientSecret: process.env.GOOGLE_SECRET!, authorization: { params: { scope: 'openid email profile' } } }), GitHubProvider({ clientId: process.env.GITHUB_ID!, clientSecret: process.env.GITHUB_SECRET! }), ]
Credentials provider
The Credentials provider allows custom username/password authentication against your own database, requiring you to implement the authorization logic and handle password hashing securely.
import CredentialsProvider from 'next-auth/providers/credentials'; import bcrypt from 'bcrypt'; CredentialsProvider({ credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" } }, async authorize(credentials) { const user = await db.user.findUnique({ where: { email: credentials.email } }); if (user && await bcrypt.compare(credentials.password, user.password)) { return { id: user.id, email: user.email, name: user.name }; } return null; } })
Session management
NextAuth.js handles sessions via JWT (stateless, stored in cookies) or database sessions (stateful, stored server-side), configurable based on your persistence and security requirements.
// next-auth options { session: { strategy: "jwt", // or "database" maxAge: 30 * 24 * 60 * 60, // 30 days }, callbacks: { async session({ session, token }) { session.user.id = token.sub; session.user.role = token.role; return session; } } }
Protected routes
Protected routes restrict access to authenticated users by checking session state either in middleware (recommended), Server Components, or client-side with redirects to login pages.
// app/dashboard/page.tsx (Server Component) import { getServerSession } from 'next-auth'; import { redirect } from 'next/navigation'; import { authOptions } from '@/lib/auth'; export default async function DashboardPage() { const session = await getServerSession(authOptions); if (!session) { redirect('/login'); } return <div>Welcome, {session.user.name}</div>; }
Middleware for auth
Middleware provides the most efficient auth protection by intercepting requests at the Edge before they reach your pages, enabling fast redirects for unauthenticated users without rendering any page content.
// middleware.ts import { withAuth } from 'next-auth/middleware'; export default withAuth({ callbacks: { authorized: ({ token }) => !!token, }, }); export const config = { matcher: ['/dashboard/:path*', '/admin/:path*', '/api/protected/:path*'], };
Server-side auth checks
Server Components and API routes can verify authentication using getServerSession() which reads cookies and validates the session without client-side JavaScript, providing secure server-rendered protected content.
// app/api/user/route.ts import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; export async function GET() { const session = await getServerSession(authOptions); if (!session) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } const userData = await db.user.findUnique({ where: { id: session.user.id } }); return Response.json(userData); }
Client-side auth state
Client Components access auth state via the useSession() hook from NextAuth.js, which provides the session data, loading state, and automatic re-fetching when the session changes.
'use client'; import { useSession, signIn, signOut } from 'next-auth/react'; export function AuthButton() { const { data: session, status } = useSession(); if (status === 'loading') return <div>Loading...</div>; if (session) { return ( <div> <span>Signed in as {session.user.email}</span> <button onClick={() => signOut()}>Sign out</button> </div> ); } return <button onClick={() => signIn()}>Sign in</button>; }
Middleware
middleware.js/ts file
The middleware file must be placed at the root of your project (same level as app/ or pages/) and exports a single function that runs before every matched request—there can only be one middleware file per project.
project-root/
├── middleware.ts ← HERE (root level)
├── app/
├── pages/
├── public/
└── next.config.js
Middleware execution
Middleware executes on every request before caching and route matching, running at the Edge (not Node.js), making it ideal for authentication, redirects, rewrites, and header manipulation with minimal latency.
Request → Middleware → Cache Check → Route Handler → Response
↓
(can redirect, rewrite,
modify headers/cookies,
or continue to route)
NextRequest and NextResponse
NextRequest extends the Web Request API with helpers for cookies, geo, and URL manipulation, while NextResponse provides static methods for creating responses, redirects, rewrites, and header/cookie modifications.
import { NextRequest, NextResponse } from 'next/server'; export function middleware(request: NextRequest) { // NextRequest helpers const url = request.nextUrl.clone(); const cookie = request.cookies.get('token'); const country = request.geo?.country; // NextResponse methods return NextResponse.next(); // continue return NextResponse.redirect(new URL('/login', request.url)); // redirect return NextResponse.rewrite(new URL('/api/proxy', request.url)); // rewrite }
URL rewrites in middleware
Rewrites allow you to internally map a request to a different path without changing the browser URL—useful for proxying, A/B testing, or serving different content based on conditions.
export function middleware(request: NextRequest) { const url = request.nextUrl.clone(); // Rewrite /old-blog/* to /blog/* internally if (url.pathname.startsWith('/old-blog')) { url.pathname = url.pathname.replace('/old-blog', '/blog'); return NextResponse.rewrite(url); } // User sees: /old-blog/post-1 // Server serves: /blog/post-1 }
Redirects in middleware
Redirects change the browser URL and send a 307 (temporary) or 308 (permanent) status code, useful for auth redirects, legacy URL handling, or geographic routing.
export function middleware(request: NextRequest) { const token = request.cookies.get('auth-token'); // Redirect unauthenticated users to login if (!token && request.nextUrl.pathname.startsWith('/dashboard')) { const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('from', request.nextUrl.pathname); return NextResponse.redirect(loginUrl); } // Permanent redirect for old paths if (request.nextUrl.pathname === '/old-page') { return NextResponse.redirect(new URL('/new-page', request.url), 308); } }
Setting headers
Middleware can add, modify, or delete response headers for security policies (CSP, CORS), caching directives, or custom headers—use NextResponse.next() with a headers option or clone and modify the response.
export function middleware(request: NextRequest) { const response = NextResponse.next(); // Security headers response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('Referrer-Policy', 'origin-when-cross-origin'); response.headers.set('X-Request-Id', crypto.randomUUID()); return response; }
Setting cookies
Middleware can read, set, and delete cookies on both the request (for downstream handlers) and response (for the browser), enabling session management, feature flags, and user preferences.
export function middleware(request: NextRequest) { const response = NextResponse.next(); // Set a cookie on response response.cookies.set('visited', 'true', { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7, // 1 week }); // Read from request, conditionally set if (!request.cookies.get('ab-test')) { response.cookies.set('ab-test', Math.random() > 0.5 ? 'A' : 'B'); } return response; }
Geolocation
Next.js middleware on Vercel provides geolocation data via request.geo, including country, region, city, and coordinates—useful for regional content, currency selection, or compliance restrictions.
export function middleware(request: NextRequest) { const { geo } = request; const country = geo?.country || 'US'; const city = geo?.city || 'Unknown'; // Block certain countries if (country === 'XX') { return new NextResponse('Not available in your region', { status: 451 }); } // Add geo headers for downstream use const response = NextResponse.next(); response.headers.set('x-user-country', country); response.headers.set('x-user-city', city); return response; }
Middleware matchers
Matchers define which paths trigger middleware execution using the config.matcher export, supporting static paths, wildcards, and regex patterns—unmatched paths skip middleware entirely for better performance.
export function middleware(request: NextRequest) { // This only runs for matched paths } export const config = { matcher: [ // Match specific paths '/dashboard/:path*', '/api/:path*', // Exclude static files and images '/((?!_next/static|_next/image|favicon.ico|public/).*)', // Regex pattern '/blog/:slug(\\d{4}/\\d{2}/.*)', ], };
Conditional middleware
Conditional middleware applies different logic based on request properties like path, method, headers, cookies, or geolocation—pattern matching and early returns keep the code clean and performant.
export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; // API routes: add CORS headers if (pathname.startsWith('/api')) { const response = NextResponse.next(); response.headers.set('Access-Control-Allow-Origin', '*'); return response; } // Admin routes: check auth if (pathname.startsWith('/admin')) { const token = request.cookies.get('admin-token'); if (!token) return NextResponse.redirect(new URL('/login', request.url)); } // Bot detection const userAgent = request.headers.get('user-agent') || ''; if (/bot|crawler|spider/i.test(userAgent)) { return NextResponse.rewrite(new URL('/static-version' + pathname, request.url)); } return NextResponse.next(); }
Edge runtime for middleware
Middleware always runs on the Edge Runtime (V8 isolates, not Node.js), providing fast cold starts and global distribution but limiting available APIs—no Node.js-specific modules, filesystem access, or native bindings.
// ✅ Edge-compatible import { jwtVerify } from 'jose'; // Pure JS JWT library import { NextRequest } from 'next/server'; // ❌ NOT Edge-compatible // import fs from 'fs'; // No filesystem // import bcrypt from 'bcrypt'; // No native bindings // import { PrismaClient } from '@prisma/client'; // No database drivers export const config = { runtime: 'edge', // This is the default for middleware };
A/B testing with middleware
Middleware enables server-side A/B testing by assigning users to cohorts via cookies and rewriting requests to variant pages—no client-side flicker since the decision happens before rendering.
export function middleware(request: NextRequest) { const bucket = request.cookies.get('ab-bucket')?.value; const url = request.nextUrl.clone(); // Only for home page if (url.pathname !== '/') return NextResponse.next(); // Assign bucket if not exists const assignedBucket = bucket || (Math.random() < 0.5 ? 'control' : 'variant'); // Rewrite to variant page url.pathname = assignedBucket === 'variant' ? '/home-variant' : '/home'; const response = NextResponse.rewrite(url); if (!bucket) { response.cookies.set('ab-bucket', assignedBucket, { maxAge: 60 * 60 * 24 * 30 }); } return response; }
Feature flags
Middleware can evaluate feature flags at the Edge to enable/disable features per user, region, or percentage rollout—integrate with services like LaunchDarkly, Statsig, or use simple cookie/header-based flags.
const FEATURES = { newCheckout: { enabled: true, percentage: 0.2 }, darkMode: { enabled: true, regions: ['US', 'CA'] }, }; export function middleware(request: NextRequest) { const response = NextResponse.next(); const userId = request.cookies.get('userId')?.value || 'anonymous'; const country = request.geo?.country || 'US'; // Percentage-based rollout (consistent per user) const hash = hashString(userId + 'newCheckout'); const inNewCheckout = FEATURES.newCheckout.enabled && (hash % 100) / 100 < FEATURES.newCheckout.percentage; // Region-based flag const hasDarkMode = FEATURES.darkMode.enabled && FEATURES.darkMode.regions.includes(country); // Pass flags via headers to be read by app response.headers.set('x-feature-new-checkout', String(inNewCheckout)); response.headers.set('x-feature-dark-mode', String(hasDarkMode)); return response; }
Configuration
next.config.js options
next.config.js is the central configuration file for Next.js, supporting both CommonJS and ES modules (.mjs), where you define build-time settings, environment variables, redirects, and extend webpack/Babel configurations.
/** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, poweredByHeader: false, compress: true, generateEtags: true, pageExtensions: ['tsx', 'ts', 'jsx', 'js', 'mdx'], distDir: '.next', cleanDistDir: true, typescript: { ignoreBuildErrors: false }, eslint: { ignoreDuringBuilds: false }, }; module.exports = nextConfig;
Custom webpack config
Next.js allows webpack customization via the webpack function in config, receiving the existing config and context—use it sparingly to add loaders, plugins, or aliases while preserving Next.js optimizations.
const nextConfig = { webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => { // Add custom loader config.module.rules.push({ test: /\.svg$/, use: ['@svgr/webpack'], }); // Add plugin config.plugins.push(new webpack.DefinePlugin({ 'process.env.BUILD_ID': JSON.stringify(buildId), })); // Add alias config.resolve.alias['@components'] = path.join(__dirname, 'components'); // Important: return modified config return config; }, };
Environment variables
Next.js supports .env files with automatic loading order (.env.local > .env.[mode] > .env), where variables prefixed with NEXT_PUBLIC_ are exposed to the browser and others remain server-only.
# .env.local (gitignored, local overrides) DATABASE_URL=postgresql://localhost/mydb API_SECRET=super-secret-key # Server only NEXT_PUBLIC_API_URL=https://api.example.com # Exposed to browser # Usage in code: # Server: process.env.DATABASE_URL ✅ # Client: process.env.DATABASE_URL ❌ undefined # Client: process.env.NEXT_PUBLIC_API_URL ✅
Base path
The basePath option prefixes all routes with a sub-path, useful when deploying Next.js under a subdirectory of a domain—all links, assets, and routing automatically adjust.
// next.config.js const nextConfig = { basePath: '/docs', }; // Result: // "/" → "/docs" // "/about" → "/docs/about" // next/link and next/router handle this automatically // In code, use router.basePath to reference it // <Link href="/about"> renders as /docs/about
Asset prefix
The assetPrefix option changes the URL prefix for static assets (JS, CSS, images), enabling CDN hosting of your assets while the app is served from a different origin.
const isProd = process.env.NODE_ENV === 'production'; const nextConfig = { assetPrefix: isProd ? 'https://cdn.example.com' : '', // JS/CSS will load from: https://cdn.example.com/_next/static/... };
Trailing slashes
The trailingSlash option controls whether routes end with a slash (/about/ vs /about), affecting both URL generation and static export file structure—important for SEO consistency and server compatibility.
const nextConfig = { trailingSlash: true, // /about → /about/ // Static export creates /about/index.html instead of /about.html }; // With trailingSlash: true // <Link href="/about"> → /about/ // router.push('/about') → /about/
Redirects configuration
Redirects in next.config.js define permanent (308) or temporary (307) URL redirections at build time, supporting path parameters, wildcards, and query string matching—executed before middleware and filesystem.
const nextConfig = { async redirects() { return [ { source: '/old-blog/:slug', destination: '/blog/:slug', permanent: true }, { source: '/docs/:path*', destination: 'https://docs.example.com/:path*', permanent: false }, { source: '/old/:path((?!keep-this).*)', destination: '/new/:path', permanent: true, }, { source: '/with-query', has: [{ type: 'query', key: 'page', value: '(?<page>.*)' }], destination: '/articles/:page', permanent: false, }, ]; }, };
Rewrites configuration
Rewrites map incoming request paths to different destination paths internally without changing the browser URL—useful for proxying APIs, gradual migrations, or vanity URLs.
const nextConfig = { async rewrites() { return { beforeFiles: [ // Checked before pages/public files { source: '/old-page', destination: '/new-page' }, ], afterFiles: [ // Checked after pages but before dynamic routes { source: '/non-existent', destination: '/somewhere-else' }, ], fallback: [ // Checked after all Next.js files { source: '/:path*', destination: 'https://legacy.example.com/:path*' }, ], }; }, };
Headers configuration
Custom HTTP headers can be set per route pattern in next.config.js, enabling security headers, caching policies, and CORS configuration without middleware overhead.
const nextConfig = { async headers() { return [ { source: '/:path*', headers: [ { key: 'X-Frame-Options', value: 'DENY' }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, ], }, { source: '/api/:path*', headers: [ { key: 'Access-Control-Allow-Origin', value: '*' }, { key: 'Access-Control-Allow-Methods', value: 'GET, POST, OPTIONS' }, ], }, { source: '/:all*(svg|jpg|png)', headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }], }, ]; }, };
Image configuration
The images config controls Next.js Image Optimization, specifying allowed domains, device sizes, image formats, and loader settings for external image sources or custom CDNs.
const nextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'images.unsplash.com' }, { protocol: 'https', hostname: '**.example.com' }, ], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], formats: ['image/avif', 'image/webp'], minimumCacheTTL: 60, dangerouslyAllowSVG: true, // Custom loader for external optimization service loader: 'custom', loaderFile: './lib/image-loader.js', }, };
Compiler options (SWC)
Next.js uses SWC (Rust-based compiler) by default instead of Babel, offering 17x faster builds—configure it for styled-components, emotion, remove console logs, or enable experimental features.
const nextConfig = { compiler: { // Remove console.* in production removeConsole: process.env.NODE_ENV === 'production', // styled-components support styledComponents: true, // Emotion support emotion: { sourceMap: true, autoLabel: 'dev-only', labelFormat: '[local]', }, // React removal of properties reactRemoveProperties: { properties: ['^data-testid$'] }, }, };
Experimental features
The experimental object enables features in development that aren't yet stable, including Server Actions, Partial Prerendering, and other cutting-edge capabilities—use with caution in production.
const nextConfig = { experimental: { serverActions: { bodySizeLimit: '2mb', allowedOrigins: ['my-domain.com'], }, ppr: true, // Partial Prerendering typedRoutes: true, // Type-safe routes optimizePackageImports: ['lodash', '@mui/material'], turbo: { // Turbopack options rules: { '*.svg': { loaders: ['@svgr/webpack'] } }, }, }, };
Output file tracing
Output file tracing automatically determines which files are needed for production deployment by analyzing imports, enabling minimal deployments without unused dependencies—essential for serverless and container environments.
const nextConfig = { outputFileTracingRoot: path.join(__dirname, '../../'), // Monorepo root outputFileTracingExcludes: { '*': ['./public/**/*', './scripts/**/*'], }, outputFileTracingIncludes: { '/api/pdf': ['./node_modules/pdfkit/**/*'], }, };
Standalone output
Standalone output creates a minimal production bundle with only necessary files, including a custom server, perfect for Docker containers—reduces image size by excluding unused node_modules.
// next.config.js const nextConfig = { output: 'standalone', }; // Dockerfile // FROM node:18-alpine // WORKDIR /app // COPY .next/standalone ./ // COPY .next/static ./.next/static // COPY public ./public // EXPOSE 3000 // CMD ["node", "server.js"]
.next/standalone/
├── server.js ← Minimal Node.js server
├── node_modules/ ← Only required dependencies
├── .next/
│ └── server/
└── package.json
Internationalization (i18n)
i18n routing (Pages Router)
The Pages Router has built-in i18n routing configured in next.config.js, automatically handling locale detection, prefixed URLs, and the locale prop in getStaticProps/getServerSideProps.
// next.config.js (Pages Router only) const nextConfig = { i18n: { locales: ['en', 'fr', 'de', 'es'], defaultLocale: 'en', localeDetection: true, // Auto-detect from Accept-Language header }, }; // URLs generated: // /about (default en) // /fr/about // /de/about // /es/about
Locale detection
Next.js detects the user's preferred locale from the Accept-Language header and cookies, automatically redirecting to the appropriate locale prefix unless disabled—customize via middleware for more control.
// Automatic detection flow: // 1. Check NEXT_LOCALE cookie // 2. Check Accept-Language header // 3. Fall back to defaultLocale // Disable auto-detection: const nextConfig = { i18n: { locales: ['en', 'fr'], defaultLocale: 'en', localeDetection: false, // Disable Accept-Language detection }, };
Locale switching
Locale switching uses Next.js Link or router with the locale prop to navigate between languages while preserving the current path, automatically updating the URL prefix and NEXT_LOCALE cookie.
// pages/index.tsx (Pages Router) import Link from 'next/link'; import { useRouter } from 'next/router'; export default function Home() { const { locale, locales, asPath } = useRouter(); return ( <nav> {locales.map((loc) => ( <Link key={loc} href={asPath} locale={loc}> {loc.toUpperCase()} {locale === loc && '✓'} </Link> ))} </nav> ); }
Domain routing
Domain routing assigns different locales to different domains or subdomains, useful for region-specific sites—each domain serves its locale without URL prefixes.
const nextConfig = { i18n: { locales: ['en', 'fr', 'de'], defaultLocale: 'en', domains: [ { domain: 'example.com', defaultLocale: 'en' }, { domain: 'example.fr', defaultLocale: 'fr' }, { domain: 'example.de', defaultLocale: 'de', http: true }, // Allow HTTP ], }, }; // example.com/about → English // example.fr/about → French // example.de/about → German
Sub-path routing
Sub-path routing (the default) prefixes all non-default locale URLs with the locale code, keeping all locales on the same domain—simpler to deploy and manage than domain routing.
┌─────────────────────────────────────────┐
│ example.com (single domain) │
├─────────────────────────────────────────┤
│ /about → English (default) │
│ /fr/about → French │
│ /de/about → German │
│ /es/about → Spanish │
└─────────────────────────────────────────┘
next-i18next
next-i18next is the most popular translation library for Pages Router, wrapping i18next with SSR support, automatic namespace loading, and seamless integration with Next.js i18n routing.
// next-i18next.config.js module.exports = { i18n: { locales: ['en', 'fr'], defaultLocale: 'en' }, localePath: './public/locales', }; // pages/index.tsx import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import { useTranslation } from 'next-i18next'; export default function Home() { const { t } = useTranslation('common'); return <h1>{t('welcome')}</h1>; } export async function getStaticProps({ locale }) { return { props: { ...(await serverSideTranslations(locale, ['common'])) } }; }
App Router i18n
The App Router doesn't have built-in i18n—instead, use dynamic route segments like [lang] combined with middleware for locale detection and redirection, giving you full control over the implementation.
// app/[lang]/layout.tsx import { getDictionary } from '@/lib/dictionaries'; export async function generateStaticParams() { return [{ lang: 'en' }, { lang: 'fr' }, { lang: 'de' }]; } export default async function Layout({ children, params: { lang } }) { const dict = await getDictionary(lang); return ( <html lang={lang}> <body>{children}</body> </html> ); } // lib/dictionaries.ts const dictionaries = { en: () => import('./dictionaries/en.json').then(m => m.default), fr: () => import('./dictionaries/fr.json').then(m => m.default), }; export const getDictionary = (locale: string) => dictionaries[locale]();
Middleware-based i18n
Middleware provides the most flexible App Router i18n solution, detecting locale from headers/cookies, redirecting to prefixed URLs, and setting headers for downstream components.
// middleware.ts import { NextRequest, NextResponse } from 'next/server'; import { match } from '@formatjs/intl-localematcher'; import Negotiator from 'negotiator'; const locales = ['en', 'fr', 'de']; const defaultLocale = 'en'; function getLocale(request: NextRequest): string { const negotiator = new Negotiator({ headers: { 'accept-language': request.headers.get('accept-language') || '' } }); return match(negotiator.languages(), locales, defaultLocale); } export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; // Check if path already has locale const pathnameHasLocale = locales.some( locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}` ); if (pathnameHasLocale) return NextResponse.next(); // Redirect to locale-prefixed URL const locale = getLocale(request); return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url)); } export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], };
Translation libraries
Popular translation libraries for Next.js include next-intl (App Router native), i18next/react-i18next, lingui, and formatjs—each offers different tradeoffs between bundle size, features, and developer experience.
// Using next-intl (recommended for App Router) // app/[locale]/page.tsx import { useTranslations } from 'next-intl'; export default function Home() { const t = useTranslations('Home'); return ( <div> <h1>{t('title')}</h1> <p>{t('greeting', { name: 'World' })}</p> <p>{t('items', { count: 3 })}</p> </div> ); } // messages/en.json { "Home": { "title": "Welcome", "greeting": "Hello, {name}!", "items": "{count, plural, =0 {No items} one {# item} other {# items}}" } }
Language detection
Language detection strategies include Accept-Language header parsing, cookie/localStorage persistence, URL path inspection, and geolocation—typically combined with fallback chains for the best user experience.
function detectLanguage(request: NextRequest): string { // 1. Check URL path (already handled by routing) // 2. Check cookie preference const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value; if (cookieLocale && locales.includes(cookieLocale)) return cookieLocale; // 3. Check Accept-Language header const acceptLang = request.headers.get('accept-language'); if (acceptLang) { const parsed = acceptLang.split(',')[0].split('-')[0]; if (locales.includes(parsed)) return parsed; } // 4. Check geolocation (Vercel) const country = request.geo?.country; const countryToLocale: Record<string, string> = { FR: 'fr', DE: 'de', US: 'en' }; if (country && countryToLocale[country]) return countryToLocale[country]; // 5. Default fallback return defaultLocale; }
RTL support
Right-to-left (RTL) language support requires setting the dir attribute on the HTML element, using CSS logical properties (start/end vs left/right), and potentially loading RTL-specific stylesheets.
// app/[lang]/layout.tsx const rtlLocales = ['ar', 'he', 'fa']; export default function RootLayout({ children, params: { lang } }) { const dir = rtlLocales.includes(lang) ? 'rtl' : 'ltr'; return ( <html lang={lang} dir={dir}> <body className={dir === 'rtl' ? 'rtl-styles' : ''}>{children}</body> </html> ); } // Use CSS logical properties // styles.css .container { margin-inline-start: 1rem; /* margin-left in LTR, margin-right in RTL */ padding-inline-end: 1rem; /* padding-right in LTR, padding-left in RTL */ text-align: start; /* left in LTR, right in RTL */ }