Back to Blog

Implementing i18n in Next.js: The Proxy Approach

7 min readRaul M. Guajardo
nextjsi18nnext-intlinternationalizationtypescript

Learn how to implement internationalization in Next.js 16 using next-intl with a proxy-based middleware pattern for clean, maintainable bilingual sites.

Implementing i18n in Next.js: The Proxy Approach

Building multilingual websites doesn't have to be complicated. In this post, I'll walk you through the internationalization (i18n) setup I use for this site - a clean, scalable approach using Next.js 16 and next-intl with a proxy-based middleware pattern.

Why This Approach?

When I started adding Spanish support to my site, I wanted a solution that:

  • Keeps routes clean - /en/about and /es/about instead of query parameters
  • Type-safe - TypeScript support for locales and translations
  • Server Component friendly - Works seamlessly with Next.js App Router
  • Maintainable - Clear separation of concerns
  • SEO-friendly - Proper URL structure for search engines

The next-intl library with a proxy middleware pattern delivers all of this beautifully.

Architecture Overview

Here's how the pieces fit together:

src/
├── lib/i18n/
│   ├── routing.ts         # Locale configuration
│   ├── navigation.ts      # Wrapped navigation exports
│   └── i18n.ts           # Request-level config & message loading
├── proxy.ts              # Middleware for locale handling
└── app/[locale]/         # Internationalized routes

messages/
├── en.json               # English translations
└── es.json               # Spanish translations

Step 1: Configure Supported Locales

First, define which locales your app supports. This configuration is the foundation for everything else.

// src/lib/i18n/routing.ts
import { defineRouting } from "next-intl/routing";
 
export const routing = defineRouting({
  // A list of all locales that are supported
  locales: ["en", "es"],
 
  // Used when no locale matches
  defaultLocale: "en",
});

Simple and clean! This is your single source of truth for supported languages.

Step 2: Set Up the Proxy Middleware

Here's where the magic happens. The proxy middleware intercepts requests and handles locale detection and routing.

// src/proxy.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "@/lib/i18n/routing";
import { NextRequest } from "next/server";
 
const intlMiddleware = createMiddleware(routing);
 
export default async function middleware(request: NextRequest) {
  // Handle internationalization
  return intlMiddleware(request);
}
 
export const config = {
  // Match all pathnames except for
  // - … if they start with `/api`, `/_next` or `/_vercel`
  // - … the ones containing a dot (e.g. `favicon.ico`)
  matcher: "/((?!api|_next|_vercel|.*\\..*).*)",
};

Important Note: In Next.js 16, middleware.ts was deprecated in favor of proxy.ts. This is the new standard for handling middleware in Next.js 16+, not just a naming preference. Next.js will automatically detect and use proxy.ts.

What the Matcher Does

The matcher is crucial - it tells Next.js which routes to process:

  • Includes: All regular routes (/about, /blog, etc.)
  • Excludes: API routes, Next.js internals, static files

This ensures your i18n logic only runs where it's needed, keeping performance optimal.

Step 3: Configure Request-Level Message Loading

Now we need to load the right translations for each request:

// src/lib/i18n/i18n.ts
import { notFound } from "next/navigation";
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
 
export default getRequestConfig(async ({ requestLocale }) => {
  // This typically corresponds to the `[locale]` segment
  let locale = await requestLocale;
 
  // Ensure that a valid locale is used
  if (!locale || !routing.locales.includes(locale as any)) {
    locale = routing.defaultLocale;
  }
 
  return {
    locale,
    messages: (await import(`../../../messages/${locale}.json`)).default,
  };
});

This function:

  1. Extracts the locale from the request
  2. Validates it against your supported locales
  3. Loads the appropriate translation file
  4. Falls back to default locale if something's wrong

Step 4: Create Navigation Helpers

Here's a game-changer - wrap next-intl's navigation utilities to automatically include the locale:

// src/lib/i18n/navigation.ts
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
 
export const { Link, redirect, usePathname, useRouter } =
  createNavigation(routing);

Now you can use these throughout your app:

import { Link } from "@/lib/i18n/navigation";
 
// This automatically becomes /en/about or /es/about
<Link href="/about">About</Link>

Critical Rule: Never import from next/navigation directly in localized pages - always use @/lib/i18n/navigation.

Step 5: Structure Your Routes

With App Router, your routes need the [locale] dynamic segment:

app/
└── [locale]/
    ├── layout.tsx
    ├── page.tsx
    ├── about/
    │   └── page.tsx
    └── blog/
        ├── page.tsx
        └── [slug]/
            └── page.tsx

Every route automatically gets the locale parameter!

Step 6: Create Translation Files

Store your translations as JSON:

// messages/en.json
{
  "home": {
    "title": "Welcome",
    "description": "Build amazing things"
  },
  "nav": {
    "about": "About",
    "blog": "Blog",
    "projects": "Projects"
  }
}
// messages/es.json
{
  "home": {
    "title": "Bienvenido",
    "description": "Construye cosas increíbles"
  },
  "nav": {
    "about": "Acerca de",
    "blog": "Blog",
    "projects": "Proyectos"
  }
}

Pro tip: Keep the structure identical between languages - it makes maintenance much easier!

Using Translations in Components

Server Components (Default)

import { getTranslations } from "next-intl/server";
 
export default async function HomePage() {
  const t = await getTranslations("home");
 
  return (
    <div>
      <h1>{t("title")}</h1>
      <p>{t("description")}</p>
    </div>
  );
}

Client Components

"use client";
 
import { useTranslations } from "next-intl";
 
export function ClientComponent() {
  const t = useTranslations("home");
 
  return <h2>{t("title")}</h2>;
}

The API is nearly identical - just async vs hook!

Type Safety with TypeScript

Want type-safe translations? Create a type definition:

// src/types/i18n.ts
import en from "@/../messages/en.json";
 
type Messages = typeof en;
 
declare global {
  interface IntlMessages extends Messages {}
}

Now TypeScript will catch typos in translation keys! 🎉

Best Practices

✅ DO:

  • Use consistent keys - Keep the same structure across all translation files
  • Namespace your translations - Group related translations (nav.*, home.*)
  • Import from i18n/navigation - Always use the wrapped navigation helpers
  • Validate locales - Handle invalid locales gracefully
  • Keep translations focused - One key = one piece of content

❌ DON'T:

  • Use middleware.ts - It's deprecated in Next.js 16+, use proxy.ts instead
  • Import from next/navigation directly - You'll lose the automatic locale handling
  • Hardcode routes - Always use the Link component or redirect from navigation
  • Forget fallbacks - Always have a default locale configured
  • Mix translation patterns - Stay consistent with namespacing

Handling Edge Cases

Invalid Locales

The i18n.ts config handles this automatically:

if (!locale || !routing.locales.includes(locale as any)) {
  locale = routing.defaultLocale;
}

API Routes

API routes are automatically excluded by the matcher, so they work normally without locale prefixes.

Static Files

The matcher pattern excludes files with extensions, so images, fonts, and other static assets work unchanged.

Performance Considerations

This setup is highly performant because:

  1. Translations load per-request - Only the needed locale is loaded
  2. Middleware is selective - Only runs on necessary routes
  3. Server Components - Most translations resolve server-side
  4. Build-time optimization - Next.js optimizes the middleware

Testing Your i18n Setup

Here's a quick checklist:

  • [ ] /en/about loads correctly
  • [ ] /es/about loads correctly
  • [ ] Invalid locale /fr/about redirects appropriately
  • [ ] Navigation between pages maintains locale
  • [ ] API routes work without locale prefix
  • [ ] Static assets load correctly

What's Next?

This foundation opens up possibilities:

  • Language switcher component - Let users toggle languages
  • Browser locale detection - Auto-select based on user preferences
  • SEO optimization - Add hreflang tags for search engines
  • Dynamic imports - Load translations on-demand for larger apps

Wrapping Up

Internationalization doesn't have to be daunting. With next-intl and this proxy-based approach, you get:

  • Clean, SEO-friendly URLs
  • Type-safe translations
  • Server Component support
  • Easy maintenance

The key is starting simple and building up. Start with two languages, get the patterns right, then expand as needed.

Have questions about implementing i18n in your Next.js app? Feel free to reach out - I'd love to hear about your use case!


Resources: