Mastering i18n in Next.js App Router with Next-intl: From Configuration to Type-safe

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Multilingual Support Is No Longer a “Pain” at the Start of Every Project

If you’ve ever worked on i18n with Page Router, you probably remember the struggle of manual configuration to avoid SEO errors or page reloads during locale switching. With App Router, everything has changed. The new approach requires us to think differently about routing and Server Components, especially when building multi-tenant SaaS with Next.js where isolation is key.

Among current libraries, next-intl stands out as the top candidate. It’s lightweight (only about 10kb gzipped) and offers excellent support for both Server and Client Components. More importantly, it helps you manage translations systematically instead of having JSON files scattered everywhere.

Quick 5-Minute Setup

First, add the library to your project:

npm install next-intl

1. Standard Folder Structure

For i18n to work smoothly, we need to wrap all routes in a dynamic segment [locale]. This is the standard that helps Next.js identify the language directly from the URL. An ideal folder structure would look like this, which is even more critical when using Nx to manage a full-stack TypeScript monorepo:

├── messages (Contains translation files)
│   ├── en.json
│   └── vi.json
├── src
│   ├── i18n.ts (Config loader)
│   ├── middleware.ts (Navigation filter)
│   └── app
│       └── [locale]
│           ├── layout.tsx
│           └── page.tsx
├── next.config.mjs

2. Configuration to Make the Machine Understand You

Create the file messages/en.json. This is where the soul of your application resides:

{
  "Index": {
    "title": "Welcome to itfromzero!",
    "description": "Learn programming from zero."
  }
}

Next, set up src/i18n.ts. This file acts as a bridge to load data corresponding to the language the user is accessing:

import {notFound} from 'next/navigation';
import {getRequestConfig} from 'next-intl/server';

const locales = ['en', 'vi'];

export default getRequestConfig(async ({locale}) => {
  if (!locales.includes(locale as any)) notFound();

  return {
    messages: (await import(`../messages/${locale}.json`)).default
  };
});

Don’t forget src/middleware.ts. It helps automatically detect the browser language or cookie to redirect users to the correct URL (e.g., /vi or /en):

import createMiddleware from 'next-intl/middleware';

export default createMiddleware({
  locales: ['en', 'vi'],
  defaultLocale: 'en'
});

export const config = {
  // Skip system files and api
  matcher: ['/', '/(vi|en)/:path*']
};

Diving Into Core Components

Middleware: The Intelligent “Gatekeeper”

When a user types yourdomain.com, the middleware immediately checks the accept-language header. If they use an English browser, it will automatically push them to /en. A small tip: always set localePrefix: 'always'. This makes the URL explicit, which is extremely beneficial for SEO because Google Bot can index each language version separately.

The Power of Server Components

The biggest advantage of next-intl is its ability to run directly on Server Components. You don’t need to add a pointless 'use client' directive just to display a greeting. This reduces the amount of JavaScript sent to the browser, significantly speeding up page load times, similar to how signals in React optimize re-renders in large-scale apps.

// src/app/[locale]/page.tsx
import {useTranslations} from 'next-intl';

export default function Index() {
  const t = useTranslations('Index');
  return (
    <div>
      <h1>{t('title')}</h1>
      <p>{t('description')}</p>
    </div>
  );
}

Real-world experience: When your JSON files reach thousands of lines, management becomes difficult. I often use JSON Formatter to quickly check syntax. Just one missing comma can crash the entire app instantly.

Upgrading to Type-safe: Say No to Typos

Typing a key incorrectly is a very common mistake. For example: the JSON key is title but in your code you type titel. Instead of letting the UI display raw code snippets, use TypeScript to catch errors while coding.

Simply create a global.d.ts file with the following content:

// global.d.ts
type Messages = typeof import('./messages/en.json');
declare interface IntlMessages extends Messages {}

Now, VS Code will automatically provide suggestions (Intellisense) for every key. If you type it wrong, the IDE will immediately highlight it in red. This is a mandatory standard for large-scale production projects.

Smooth Language Switcher Implementation

Don’t use the <a> tag to change languages because it will reload the page, causing a disconnected experience. Instead, take advantage of the optimized navigation functions from next-intl.

Create a src/navigation.ts file for reuse:

import {createSharedPathnamesNavigation} from 'next-intl/navigation';

export const locales = ['en', 'vi'] as const;
export const {Link, redirect, usePathname, useRouter} = createSharedPathnamesNavigation({locales});

After that, the language selection component will be as simple as this:

'use client';
import {usePathname, useRouter} from '@/navigation';

export default function LocaleSwitcher() {
  const pathname = usePathname();
  const router = useRouter();

  const changeLocale = (nextLocale: string) => {
    // Switch locale while maintaining the current route
    router.replace(pathname, {locale: nextLocale});
  };

  return (
    <select onChange={(e) => changeLocale(e.target.value)}>
      <option value="vi">Vietnamese</option>
      <option value="en">English</option>
    </select>
  );
}

“Hard-earned” Lessons During Implementation

  • SEO Metadata: Use generateMetadata to translate meta tags as well. A multilingual website where every page title is the same will be poorly rated by Google. This is a crucial step when transforming React apps into professional PWAs to ensure they are discoverable.
  • Pluralization: Don’t use if-else in your code. Leverage the ICU format of next-intl: "items": "{count, plural, =0 {Empty} one {1 item} other {# items}}".
  • Number and Date Formatting: Always use the format.dateTime function instead of .toLocaleString(). This helps avoid HydrationMismatch errors between Server and Client due to timezone differences.

Implementing i18n from day one will save you weeks of work later, much like setting up building background jobs for Next.js early to handle heavy tasks. Although the initial setup takes some effort, the professionalism and global user reach are well worth the reward.

Share: