18ways blog

How to Add Multiple Languages to a Next.js App

How to add multilingual i18n and localisation to an existing Next.js app without hurting SEO or rebuilding from scratch.

I18n (internationalisation), l10n (localisation), multilingual support… however you say it, eventually you need to update your Next.js app to support more than one language.

The difficulty is not translating one sentence. It’s adding multiple languages to a real Next.js app without breaking SEO, tangling up your codebase, or building a maintenance problem for yourself.

If you want to jump straight to the code, check out the 18ways-next GitHub examples.

Set up your infra first

Before anything else, we need to install our packages. We’ll be setting up the 18ways libs, but tools like i18next work well if your project is simple, and only has static content.

bash
npm install @18ways/next @18ways/react

Create your config file:

js
// 18ways.config.js
module.exports = {
  apiKey: 'pk_dummy_demo_token',
  baseLocale: 'en-GB',
  router: 'app', // 'app', or 'path' depending on which Next.js router you are using
};

Wrap your Next.js config:

js
// next.config.js
const { withWays } = require('@18ways/next/config');
 
const nextConfig = {
  /*
   * your normal Next.js config here
   */ 
};
 
module.exports = withWays(nextConfig);

Add a root proxy so / can redirect to the right locale:

js
// proxy.js
export { default, config } from '@18ways/next/proxy';

Then add a localized layout:

jsx
// app/layout.jsx
import './styles.css';
import { WaysRoot } from '@18ways/next/server';
 
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className="next-demo-body">
        <WaysRoot>{children}</WaysRoot>
      </body>
    </html>
  );
}

And we’re done! You now have:

Translate your first page

You can now translate your first page. Tools like i18next will require you to break out all of your text into translation keys, and then reference them in your code. Exactly what this look like will depend on the library you have chosen.

If you’re using 18ways, you can simply wrap the text you want to translate in a <T> component:

jsx
// src/components/MyExampleComponent.jsx
import { useT, T } from '@18ways/react';
 
export default function MyExampleComponent() {
  const t = useT();
 
  return (
    <div>
      <T>Hello world!</T>
      <img
        src="https://example.com/image.png"
        alt={t('Example image')}
      />
    </div>
  );
}

You likely want to allow users to switch their language choice:

jsx
// src/components/Footer.jsx
import { T, LanguageSwitcher } from '@18ways/react';
 
export default function Footer() {
  return (
    <footer>
      <T>My footer content</T>
      <LanguageSwitcher />
    </footer>
  );
}

Watch out for pitfalls

Once the plumbing is in place, there are a few ways these projects usually go wrong.

Server rendering

Server-side rendering (SSR) is what allows your Next.js app to server HTML pre-rendered. This is vital for both SEO, and for the user to not see flashes of bad content or incorrect language.

If you’re using a library like i18next, you need to be very careful that your translations are being loaded and populated in during the server-side render. You can test this by checking the view-source: of your page, e.g. view-source:http://localhost:3000/. You should check this in production, too, to make sure it also works in your production build.

If you’re using 18ways, don’t worry about this. It’s all handled for you.

Translation keys

If you’re using 18ways, you don’t need to worry about this at all. 18ways doesn’t need translation keys, you can leave your text in place like normal.

Many i18n systems require you to break apart your code into translation keys. It’s important to be careful with how you name these keys.

Bad keys are vague:

js
// bad-keys.en-GB.js
module.exports = {
  title: 'Continue',
  button: 'Pay now',
  label: 'Home',
};

Those keys tell translators and developers almost nothing about where the text appears.

Better keys include context:

js
// better-keys.en-GB.js
module.exports = {
  'checkout.payment.primaryButton': 'Pay now',
  'checkout.payment.stepTitle': 'Complete your payment',
  'account.sidebar.homeLink': 'Home',
};

Also avoid dynamically generating keys:

js
// bad-dynamic-keys.js
const key = `checkout.${status}.${buttonType}`;
const translatedText = t(key);

This will break IDE tools that try and make translation keys less unbearable. It will also make it extremely difficult to search for and clean up old translation keys.

js
// better-dynamic-keys.js
const keyMap = {
  success: {
    primary: t('checkout.success.primary'),
    default: t('checkout.success.default'),
  },
  error: {
    primary: t('checkout.error.primary'),
    default: t('checkout.error.default'),
  },
};
 
const translatedText = keyMap[status][buttonType];

This is less DRY, but is the best way to stop translation keys being unmanageable.

Even better is to use a tool like 18ways, then you never need translation keys at all:

jsx
// app/[lang]/checkout/page.jsx
<T>Pay now</T>

Locale detection

Locale detection means deciding which language a user should see before they explicitly switch.

That usually involves some combination of:

In plain Next.js middleware, that often looks like this:

js
// middleware.js
 
/**
 * You don't need any of this if you're using 18ways 
 */
 
import { NextResponse } from 'next/server';
 
const acceptedLocales = ['en-GB', 'fr-FR'];
 
export function middleware(request) {
  const savedLocale =
    request.cookies.get('preferred-locale')?.value;
  const browserLocale =
    request.headers
      .get('accept-language')
      ?.split(',')[0] || 'en-GB';
 
  const locale = acceptedLocales.includes(savedLocale)
    ? savedLocale
    : acceptedLocales.includes(browserLocale)
      ? browserLocale
      : 'en-GB';
 
  if (request.nextUrl.pathname === '/') {
    return NextResponse.redirect(
      new URL(`/${locale}`, request.url)
    );
  }
 
  return NextResponse.next();
}

Some libraries have helpers to make this easier, to varying degrees. With 18ways, the initial detection and the redirect layer is handled for you.

Dates and locale-specific primitives

Different locales format dates, numbers, and money differently.

For example:

In raw JavaScript, you handle that yourself with Intl:

js
// formatting-dates-and-currency.js
const myLocale = getCurrentLocale(); // depends on your lib
 
const someTimestamp = new Date('2026-04-13T09:00:00Z');
const dateLabel = new Intl.DateTimeFormat('fr-FR', {
  dateStyle: 'long',
}).format(someTimestamp);
 
const someMoney = {
  amount: 1999,
  currency: 'EUR',
};
const moneyLabel = new Intl.NumberFormat('de-DE', {
  style: 'currency',
  currency: someMoney.currency,
}).format(someMoney.amount);
 
const translatedText = t(
  'my.translation.key',
  { dateLabel, moneyLabel }
);

If you’re using 18ways, this is handled for you:

jsx
const someTimestamp = new Date('2026-04-13T09:00:00Z');
const someMoney = {
  amount: 1999,
  currency: 'EUR',
};
 
<T>My text with {{ someTimestamp }} and {{ someMoney }}</T>

Joining up strings

Do not build translated UI by stitching together fragments:

jsx
// bad-string-joining.jsx
const clickHereText = t('click.here');
const toGetStartedText = t('to.get.started')
<p><a href="#">{clickHereText}</a> {toGetStartedText}.</p>

This will break in multiple languages.

You might say “👉👉Click here👈👈 to get started” in English, but in French it is more natural to say “Pour commencer, 👉👉cliquez ici👈👈”. Some languages like Japanese will even need words both before and after like “始めるには👉👉こちら👈👈をクリックしてください”.

The sentence structure can change, so splitting it into pieces makes good translation much harder.

Whole sentences translate better because translators can reorder words naturally and see the meaning as a complete unit.

If you’re translating JSX like this:

jsx
// rich-text-message.jsx
<p>
  Something <strong>that we want to be bold</strong>
</p>

you will have to consult your i18n library on how to manage this, as it’s handled very differently by different libraries.

If you’re using 18ways, you can simply translate the whole JSX block like normal.

jsx
// rich-text-message.jsx
<p>
  <T>Something <strong>that we want to be bold</strong></T>
</p>

Variables

Variables let you keep the sentence whole while still inserting runtime values.

Most i18n libraries support this in some form. With 18ways:

jsx
// app/[lang]/page.jsx
<T>Hello {{ name: 'Ada' }}</T>

That pattern works well for:

The sentence stays readable, and the value stays explicit.

If you need to have the variable itself also be translated, make sure to wrap that up in a t(...):

jsx
const animal = t('dog');
<T>Favourite animal: {{ animal }}</T>

Plurals

Plural rules differ by language. English has one plural form (1 year, 2 years, etc.). Polish has multiple plural forms (1 rok, 2 lata, 5 lat). Japanese has no plural form at all (1 年, 2 年, 3 年).

Most i18n libraries let you specidy ICU-like syntax to handle plurals:

jsx
// app/[lang]/page.jsx
<T>
  {{
    unreadCount,
    format:
      'plural, =0{No unread messages} =1{One unread message} other{{unreadCount} unread messages}',
  }}
</T>

The above will work in 18ways, but you can also just do this in most cases:

jsx
// app/[lang]/page.jsx
<T>{{ unreadCount }} unread messages</T>

18ways will handle plurals for you!

Get started now

Adding multiple languages to a Next.js app does not need to become a rewrite project.

If you’re not using a solution like 18ways — make sure to get your routing and SSR right, then work through moving your copy out to translation keys, being sure to avoid the common i18n pitfalls.

If you are using 18ways, then this is all handled for you, and you just need to start wrapping your text in <T> blocks!