Software localization

A Deep Dive into Next.js App Router Localization with next-intl

Take a deep dive into Next.js localization using an App Router demo and next-intl. We'll explore routing, Server and Client Components, and much more.
React l10n and i18n blog post featured image | Phrase

Next.js, hailed as the premier full-stack React framework, elegantly navigates through common challenges such as routing and server-side rendering, making them a breeze. With its 13th iteration, Next.js unveiled the App Router, ushering in more rendering flexibility through the innovative React Server Components (RSC). However, the App Router does add a twist to Next.js internationalization (i18n), making it a tad tricker than it was with the Pages Router.

To the rescue comes the nifty next-intl library. Tailor-made for Next.js, next-intl simplifies Next.js App Router i18n by managing localized routing and supporting Server Components (in beta). It also offers standard i18n features like translation management and date/number formatting.

🔗 Resource » For localizing a Next.js app using the Pages Router and the i18next library, refer to our step-by-step guide to Next.js internationalization.

In this hands-on guide, we’ll build an App Router demo app and localize it with next-intl. We’ll cover routing, Server and Client Components, translations, formatting, and more.

💡 Learn more » Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and to different regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.

Our demo app

We figure some climate optimism could be congenial these days, so our fictional demo, Dirkha, will list positive climate action stories from around the world (sourced from Euronews).

Our demo app before localization | Lokalise

We’ll build this app and localize it step-by-step. Let’s get started.

📣 Shout-out » The app icon is based on the green icon by Flatart from The Noun Project. The oyster photo is by Ben Stern from Unsplash.

Package versions used

We used the following NPM packages when developing this app.

Library Version used Description
typescript 5.2.2 Our programming language of choice.
next 13.5.1 The full-stack React framework.
react 18.2.0 A modestly popular UI library.
next-intl 3.0.0-beta.19 Our i18n library.
tailwindcss 3.3.3 For styling; optional for our purposes.

✋ Heads up » The article uses the beta version of next-intl for Server Component support, so it may become outdated by the time you read this. We’ll update the article once next-intl’s Server Component support is stable.

Alright, let’s build shall we? From the command line, we’ll spin up a fresh Next.js app:

npx create-next-app@latestCode language: Bash (bash)

The usual setup Q&A follows, of course. Here’s what I entered:

What is your project named? next-i18n-09-2023 (enter what you like here)
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes (optional here)
Would you like to use Tailwind CSS? Yes (optional here)
Would you like to use src/ directory? No (optional, but you’ll have to adjust if you choose “Yes”)
Would you like to use App Router? Yes
You you like to customize the default import alias? No (optional)

A simple mock API

Our Next app will fetch data from a mock API built with Express. The code for this API is a bit outside the scope of this article, but you can get it from our GitHub repo:

If you want to code along with us, grab the two files above and put them in a _server directory at the root of your Next app project.

Don’t forget to install Express:

npm install expressCode language: Bash (bash)

And remember to run the Express server in a separate command line instance:

node _server/index.jsCode language: Bash (bash)

The server will pipe some articles to our Next app in JSON format, which looks like this:

// _server/data.json

{
  "featured": {
    "id": "7d9b3c04-1e8a-4db7-a982-6f5e8729d5a7",
    "author": "Hannah Browns",
    "publishedAt": "2023-08-23",
    "imgUrl": "/stories/ben-stern-4n96lyJd2Xs-unsplash.jpg",
    "sourceUrl": "https://www.euronews.com/green/2023/08/23/meet-the-company-using-discarded-oyster-shells-to-cut-energy-costs-and-keep-frances-buildi",
    "translations": {
      "en-US": {
        "title": "The paint made from oyster shells could...",
        "body": "Cool Roof France (CRF) uses discarded oyster..."
      },
      "ar-EG": {
        "title": "الطلاء المصنوع من قشر الأسماك..."
        "body": "كول روف فرنسا (CRF) تستخدم قشور الأوستر المهملة لإنتاج طلاء للسقف يعكس الحرارة ويخفض درجات حرارة المباني بمقدار يصل إلى سبع درجات مئوية. هذا الابتكار يقلل استهلاك الطاقة الناتج عن تشغيل أجهزة ..."
      }
    }
  },
  "feed": [
    {
      "id": "f28e58f1-56f7-4b2b-9d9c-eb5a4a6e25d3",
      "author": "Euronews Green with AP",
      "publishedAt": "2023-08-15",
      "sourceUrl": "https://www.euronews.com/green/2023/08/15/court-rules-children-have-a-right-to-a-healthy-environment-in-major-blow-to-fossil-fuel-in",
      "translations": {
        "en-US": {
          "title": "🙌 Major win: Young climate activists...",
          "body": "A US court has ruled that children..."
        },
        "ar-EG": {
          "title": "🙌 فوز كبير: يحرز النشطاء الشباب في مجال المناخ..."
          "body": " المناخ. على الرغم من أنه يمهد الطريق..."
        }
      }
    },

    // ...      
  ]
}Code language: JavaScript (javascript)

Building the demo

OK, with that out of the way, let’s build the Next app itself. We’ll start by updating the root layout to look like the following.

// app/layout.tsx

import type { Metadata } from "next";
import Footer from "./_components/Footer";
import Header from "./_components/Header";
import "./globals.css";

export const metadata: Metadata = {
  title: "Dirkha - Good Green News",
  description: "Created using Next.js",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Header />
        {children}
        <Footer />
      </body>
    </html>
  );
}Code language: JavaScript (javascript)

You’ve probably noticed the presentational Header and Footer components we’ve pulled in above. Those are next:

// app/_components/Header.tsx

import Image from "next/image";
import Link from "next/link";

export default function Header() {
  return (
    <header className="...">
      <div className="...">
        <Link href="/" className="...">
          <Image
            src="/dirkha-logo.svg"
            alt="Dirkha Logo"
            width={50}
            height={50}
          />
          <div>
            <div>Dirkha</div>
            <div>Good Green News</div>
          </div>
        </Link>
        <Link href="/about">About</Link>
      </div>
    </header>
  );
}Code language: JavaScript (javascript)

🗒️ Note » We’re omitting most CSS code in this article for brevity. You can get the full code of our demo app, including CSS, from our GitHub repo.

// app/_components/Footer.tsx

export default function Footer() {
  return (
    <footer>
      <p>
        news source:{" "}
        <a
          href="https://www.euronews.com/green/2023/08/07/here-are-all-the-positive-environmental-stories-from-2023-so-far"
        >
          euronews.green
        </a>
        {"; "}
        created with{" "}
        <a href="https://nextjs.org/">next.js</a>
      </p>
    </footer>
  );
}Code language: JavaScript (javascript)

Our home page is loaded when the root (/) route is hit. It pulls the news data from our mock API, which is run by the Express server, and displays it.

// app/page.tsx

import ArticleTeaser from "./_components/ArticleTeaser";
import FeaturedArticle from "./_components/FeaturedArticle";
import { ApiData } from "./types";

export default async function Home() {
  // Load all news data from our mock API
  const data: ApiData = await fetch("http://127.0.0.1:4000", {
    // The cache will be refreshed every
    // 10 seconds. In production, we would 
    // probably cache for something like
    // once a day.
    next: { revalidate: 10, },
  }).then((res) => res.json());

  const { featured, feed } = data;

  return (
    <main>
      <div className="...">
        <div className="...">
          <div className="...">
            <span>Featured story</span>
          </div>

          <FeaturedArticle article={featured} />
        </div>

        <div className="...">
          <div className="...">
            <span>The grapevine</span>
            <span>{feed.length} stories</span>
          </div>

          {feed.map((article) => (
            <ArticleTeaser article={article} key={article.id} />
          ))}
        </div>
      </div>
    </main>
  );
}Code language: JavaScript (javascript)

We’ve declared some TypeScript types in app/types.ts to ensure that we use our mock API data consistently.

// app/types.ts

// We'll get to these locales when we address
// localization a bit later.
export type Locale = "en-US" | "ar-EG";

export interface ApiData {
  featured: Article;
  feed: Article[];
}

export interface ArticleTranslation {
  title: string;
  body: string;
}

export type ArticleTranslations = Record<Locale, ArticleTranslation>;

export interface Article {
  id: string;
  author: string;
  publishedAt: string;
  imgUrl?: string;
  sourceUrl: string;
  translations: ArticleTranslations;
}Code language: JavaScript (javascript)

Our root page utilizes a couple of simple presentational components, <FeaturedArticle> and <ArticleTeaser>.

// app/_components/FeaturedArticle.tsx

import Image from "next/image";
import Link from "next/link";
import { Article } from "../types";

export default function FeaturedArticle({ article }: { article: Article }) {
  const { imgUrl, author, publishedAt } = article;

  // We'll load our English translations for now,
  // and localize later.
  const { title } = article.translations["en-US"];

  return (
    <article className="...">
      <Image
        src={imgUrl!}
        alt="Featured article image"
        width={4630}
        height={4000}
      />
      <div className="...">
        <h2>
          <Link href={`/articles/${article.id}`}>
            {title}
          </Link>
        </h2>
        <div className="...">
          <p>by {author}</p>
          <p>{publishedAt}</p>
        </div>
      </div>
    </article>
  );
}Code language: JavaScript (javascript)
// app/_components/ArticleTeaser.tsx

import Link from "next/link";
import { Article } from "../types";

export default function ArticleTeaser({ article }: { article: Article }) {
  return (
    <article>
      <h2>
        <Link href={`/articles/${article.id}`}>
          {article.translations["en-US"].title}
        </Link>
      </h2>
      <div>
        <p>by {article.author}</p>
        <p>{article.publishedAt}</p>
      </div>
    </article>
  );
}Code language: JavaScript (javascript)

With these components, we should see our home page rendering when we run our app.

home page rendering when we run our app | Lokalise

Clicking on any of the articles on the home page takes us to a standalone article page, which loads the article using an id route parameter.

// app/articles/[id]/page.tsx

import { Article } from "@/app/types";

export default async function ArticlePage({
  params: { id },
}: {
  params: { id: string };
}) {
  const article: Article = await fetch(`http://127.0.0.1:4000/${id}`, {
    next: { revalidate: 10 },
  }).then((res) => res.json());

  const { author, publishedAt, sourceUrl } = article;
  const { title, body } = article.translations["en-US"];

  return (
    <main>
      <h1>{title}</h1>

      <div className="...">
        <div className="...">
          <p>by {author}</p>
          <p>published {publishedAt}</p>
        </div>

        <div className="...">
          <p>
            source: <a href={sourceUrl}>euronews.green</a>
          </p>{" "}
          <p>
            [summarized by <a href="chat.openai.com/">ChatGPT</a>]
          </p>
        </div>
      </div>

      <p>{body}</p>
    </main>
  );
}Code language: JavaScript (javascript)

Standalone article | Lokalise

That about does it for our starter demo. Let’s get to localizing.

🔗 Resource » You can get the full code of our starter demo from the start branch of our GitHub repo.

How do I localize my Next.js app with next-intl?

Let’s look at the basic recipe for localizing a Next.js app with next-intl, assuming a focus on Server Components:

  1. Install and set up next-intl.
  2. Organize translation message files by locale.
  3. Configure localized routing.
  4. Use t() for component string translations.
  5. Localize Client Components.
  6. Localize async components.
  7. Use generateStaticParams() for production builds.
  8. Enable static rendering with unstable_setRequestLocale().
  9. Use next-intl/Link for localized navigation.
  10. Apply localized formatters for dates/numbers.
  11. Localize page metadata.

We’ll work through these steps one at a time.

How do I install and configure next-intl?

Installing next-intl is through NPM.

npm install next-intl@^3.0.0-beta.19Code language: Bash (bash)

✋ Heads up » Refer to the next-intl Getting Started docs for the latest beta version, but be aware that breaking changes may occur between beta versions.

Alright, let’s add our first translation file. We’ll work with two locales in this article: English as spoken in the United States (en-US) and Arabic as spoken in Egypt (ar-EG). Feel free to use any locales you want instead.

🗒️ Note » A locale indicates a language and a region, represented by codes like fr-CA for Canadian French. While “language” and “locale” are sometimes used interchangeably, it’s important to know the difference. Here’s a handy list of locale codes.

We’ll create a new app/_translations directory and place our English translation message file in it.

// app/_translations/en-US.json

{
  // Component/page namespace
  "Header": {
    // Translations under namespace
    "title": "Dirkha",
    "subtitle": "Good Green News"
  }
}Code language: JSON / JSON with Comments (json)

Next, let’s add an i18n.ts config file at the root of our project.

// i18n.ts

import { getRequestConfig } from "next-intl/server";

// Create this configuration once per request and 
// make it available to all Server Components.
export default getRequestConfig(async ({ locale }) => ({
  // Load translations for the active locale.
  messages: (await import(`./app/_translations/${locale}.json`)).default,
}));Code language: JavaScript (javascript)

Now we can wire up the i18n.ts file in next.config.js:

// next.config.js

+ const withNextIntl = require("next-intl/plugin")("./i18n.ts");

/** @type {import('next').NextConfig} */
const nextConfig = {};

- module.exports = nextConfig;
+ module.exports = withNextIntl(nextConfig);Code language: Diff (diff)

With the App Router, our localized routing needs to be handled in Next.js Middleware. Let’s create a middleware.ts file at the root of our project and pull in next-intl’s custom middleware.

// middleware.ts

import createMiddleware from "next-intl/middleware";

export default createMiddleware({
  // Our app's supported locales. We can have
  // as many as we want.
  locales: ["en-US", "ar-EG"],

  // If this locale is matched, pathnames work without
  // a prefix (e.g. `/about`)
  defaultLocale: "en-US",
});

export const config = {
  // Skip all paths that should not be internationalized.
  // This skips the folders "api", "_next" and all files
  // with an extension (e.g. favicon.ico)
  matcher: ["/((?!api|_next|.*\\..*).*)"],
};Code language: JavaScript (javascript)

🔗 Resource » If you have other Middleware in your project and you want to ensure it works nicely with next-intl, check out Composing other middlewares in the next-intl docs.

We want our active locale to be a URL prefix, e.g. /ar-EG/about loads the about page in Arabic. To make this prefix a dynamic route param, let’s place all routed files under a new [locale] directory.

Here’s our relevant app hierarchy before:

.
└── app
    ├── about
    │   └── page.tsx
    ├── articles
    │   └── [id]
    │       └── page.tsx
    ├── layout.tsx
    └── page.tsxCode language: plaintext (plaintext)

And here it is after:

.
└── app
    └── [locale]
        ├── about
        │   └── page.tsx
        ├── articles
        │   └── [id]
        │       └── page.tsx
        ├── layout.tsx
        └── page.tsxCode language: plaintext (plaintext)

That’s it for major setup. If we run our app now, it should behave exactly the same as before. That’s because we haven’t wired up our translations yet. We’ll do that in the next section.

It may seem like a bit of juggling to get next-intl configured, but it is significantly less work than rolling our own solution. By plugging next-intl into our project we just got localized routing, locale detection, translation management, formatting, and more! Let’s see it all in action.

🔗 Resource » Read the next-intl Getting Started guide for more info on installation and setup.

How do I work with basic translations?

The app/_translations/en-US.json file we created earlier is being loaded by next-intl for each request, but we’re not using it in our pages. Let’s create its Arabic counterpart so we can start localizing our pages:

// app/_translations/ar-EG.json

{
  "Header": {
    "title": "دِرخَة",
    "subtitle": "أخبار بيئية إيجابية"
  }
}Code language: JSON / JSON with Comments (json)

We’ll pull these translations into our Header component:

// app/_components/Header.tsx

+ import { useTranslations } from "next-intl";
  import Image from "next/image";
  import Link from "next/link";

  export default function Header() {
+   // Pull translations under the "Header"
+   // namespace.
+   const t = useTranslations("Header");

    return (
      <header>
        <Link href="/">
          {/* ... */}

          <div className="...">
            <div className="...">
-             Dirkha
+             {t("title")}
            </div>
            <div className="...">
-             Good Green News
+             {t("subtitle")}
            </div>
          </div>
        </Link>

        {/* ... */}
    </header>
  );
}Code language: Diff (diff)

The useTranslations() hook by next-intl automatically loads translations based on the active locale. On the /en-US route, it loads English translations, and on /ar-EG, it loads Arabic.

The hook provides a t() function: when given a key from a specified namespace, t() returns the corresponding translation. For instance, using t("title") fetches the Header.title translation if the namespace Header was specified with useTranslations("Header").

With this in place, we can see our localization taking form:

Our app’s title and subtitles translated into English and Arabic.

How do I configure localized routing?

We set up basic localized routing when we installed and configured next-intl earlier. Our current setup uses next-intl’s default prefixed-based routing with optional prefixes.

Let’s go through how this works. To keep things simple for now, let’s turn off next-intl’s default locale detection. (We’ll circle back to locale detection a bit later).

// middleware.ts

 import createMiddleware from "next-intl/middleware";

 export default createMiddleware({
   locales: ["en-US", "ar-EG"],
   defaultLocale: "en-US",
+  localeDetection: false,
 });

 export const config = {
   matcher: ["/((?!api|_next|.*\\..*).*)"],
 };Code language: Diff (diff)

Now let’s look at some examples to see the current configuration in action:

The user visits Loaded page Active locale
/ Home defaultLocale (English)
/about About defaultLocale (English)
/ar-EG Home Arabic
/ar-EG/about About Arabic

Forcing the locale prefix

What if we wanted to always make the locale prefix explicit so that even our default locale routes are prefixed, e.g. /en-US, /en-US/about? That’s easy to do with next-intl.

// middleware.ts

 import createMiddleware from "next-intl/middleware";

 export default createMiddleware({
   locales: ["en-US", "ar-EG"],
   defaultLocale: "en-US",
   localeDetection: false,
+  localePrefix: "always",
 });

 // ...Code language: Diff (diff)

This changes our app’s behavior for the default locale:

The user visits The app does
/ Redirects to the defaultLocale, /en-US, and sets en-US as the active locale.
/about Redirects to the defaultLocale, /en-US/about, and sets en-US as the active locale.

The routes for non-default locales would work the same as before.

🤿 Go deeper » next-intl also supports domain-based routing and even more routing options.

How do I automatically detect the user’s locale?

Automatic locale detection is enabled by default in next-intl. We actually turned it off earlier to simplify our reasoning about localized routing. Let’s turn it back on now.

// middleware.ts

import createMiddleware from "next-intl/middleware";

export default createMiddleware({
  locales: ["en-US", "ar-EG"],
  defaultLocale: "en-US",
- localeDetection: false,
+ localeDetection: true,
  localePrefix: "always",
});

// ...Code language: Diff (diff)

Again, this is the default, so we could have deleted the setting entirely and got the same behavior as setting it to true. So how does locale detection work in next-intl? Let’s look at an example to illustrate.

Let’s say a user with a preferred Arabic-Lebanon locale (ar-LB) visits the site. next-intl will redirect her to the closest supported ar-EG locale, storing a locale cookie for future visits. On a later visit, next-intl will load the site in ar-EG based on the cookie (despite the user’s browser preference).

Later, the user manually selects the en-US locale and next-intl updates the locale cookie to en-US. On subsequent visits, the site loads in the en-US locale.

Prioritized list of preferred locales in your browser settings | Lokalise

🤿 Go deeper » Under the hood, next-intl uses the HTTP request’s Accept-Language to detect the user’s preference on the first visit. Read more about next-intl’s locale detection in the official docs.

Right now a user would need to type a localized URL in their browser address bar to manually select a locale. Let’s enhance this UX by giving our users a locale switcher.

How do I build a language switcher?

We’ll build our locale switcher as a React component, of course. First, let’s write a reusable module for our locale config.

// i18nconfig.ts
// (Not to be confused with i18n.ts)

// Remember the Locale type is just a
// union: "en-US" | "ar-EG"
import { Locale } from "./app/types";

export const defaultLocale: Locale = "en-US";

export const locales: Locale[] = ["en-US", "ar-EG"];

export const localeNames: Record<Locale, string> = {
  "en-US": "English",
  "ar-EG": "العربية (Arabic)",
};Code language: JavaScript (javascript)

A quick refactor to our middleware before we continue:

// middleware.ts

  import createMiddleware from "next-intl/middleware";
+ import { defaultLocale, locales } from "./i18nconfig";

  export default createMiddleware({
-   locales: ["en-US", "ar-EG"],
+   locales,
-   defaultLocale: "en-US",
+   defaultLocale,
    localeDetection: true,
    localePrefix: "always",
  });

// ...Code language: Diff (diff)

Now let’s use our new config to build the locale switcher.

// app/_components/LocaleSwitcher.tsx

"use client";

import { useLocale } from "next-intl";
import { usePathname, useRouter } from "next-intl/client";
import { localeNames, locales } from "../../i18nconfig";

export default function LocaleSwitcher() {
  const locale = useLocale();
  const router = useRouter();
  const pathName = usePathname();

  const switchLocale = (e: React.ChangeEvent<HTMLSelectElement>) => {
    router.push(pathName, { locale: e.target.value });
  };

  return (
    <div>
      <select
        value={locale}
        onChange={switchLocale}
      >
        {locales.map((loc) => (
          <option key={loc} value={loc}>
            {localeNames[loc]}
          </option>
        ))}
      </select>
    </div>
  );
}Code language: JavaScript (javascript)

In Next.js’ App Router, components are Server Components by default, always rendered server-side and re-fetched as needed. However, Server Components can’t use browser APIs like the DOM.

To use such APIs, we mark our component with "use client" to treat it as a Client Component. We can then use next-intl’s client router to navigate the user to their selected locale.

For example, if a user is on the English version of the About page and they switch to Arabic, they’re taken from /en-US/about to /ar-EG/about.

If we place the LocaleSwitcher inside our Header component, we can see this navigation working:

Our language switcher in action | Phrase

📣 Shout-out » Thanks to Kmg Design for providing their chevron icon on The Noun Project.

How do I localize Client Components?

Server Components enhance performance and security by handling data fetching and i18n server-side, reducing client bundle size. For localizing Client Components like LocaleSwitcher, next-intl offers a workflow that requires converting all localized components to Client Components.

To keep the benefits of Server-first while accommodating some Client Components, next-intl provides four options:

  1. Passing translations to Client Components
  2. Moving state to the server side
  3. Providing individual messages
  4. Providing all messages

We’ll cover the first option, passing translations to Client Components. Here’s how this basically works:

import { useTranslations } from "next-intl";
import MyClientComponent from "./MyClientComponent";

export function MyServerComponent() {
  // We use next-intl on the server.
  const t = useTranslations("MyServerComponent");

  return (
    <div>
      <p>{t("title")}</p>
      {/* Pass translated text to a Client Component
          as a regular string prop. */}
      <MyClientComponent text={t("clientText")} />
    </div>
  );
}Code language: JavaScript (javascript)
"use client";

export function MyClientComponent({ clientText }: { clientText: string }) {
	return (
    <div>
      {/* Translated to active locale. */}
      <p>{clientText}</p>
    </div>
  );
}Code language: TypeScript (typescript)

Easy enough. Let’s localize the locale names in our locale switcher to take this further. To do so, we’ll create a custom hook to make the locale names reusable.

First, let’s add the locale names to our translation files:

// app/_translations/en-US.json

  {
+   "useLocaleNames": {
+     "en-US": "English",
+     "ar-EG": "Arabic (العربية)"
+   },
    "Header": {
      // ...
    }
  }Code language: Diff (diff)
// app/_translations/ar-EG.json

  {
+   "useLocaleNames": {
+     "en-US": "الإنجليزية (English)",
+     "ar-EG": "العربية"
+   },
    "Header": {
      // ...
    }
  }Code language: Diff (diff)

Next, let’s build that custom hook.

// app/_hooks/useLocaleNames.ts

import { useTranslations } from "next-intl";
import { locales } from "../../i18nconfig";
import { Locale } from "../types";

// Returns a localized map of locale names
// in the shape: { "en-US": "English", ... }
export default function useLocaleNames(): Record<Locale, string> {
  const t = useTranslations("useLocaleNames");

  return locales.reduce(
    (acc, locale) => {
      acc[locale] = t(locale) as string;
      return acc;
    },
    {} as Record<Locale, string>,
  );
}Code language: TypeScript (typescript)

We’ll want to use this hook inside a Server Component, passing the locale names to the nested Client Component as a prop. Our Server Header houses the Client LocaleSwitcher, so it’s the natural place to use our new hook.

// app/_components/Header.tsx

  import { useTranslations } from "next-intl";
  // ...
+ import useLocaleNames from "../_hooks/useLocaleNames";
  import LocaleSwitcher from "./LocaleSwitcher";

  export default function Header() {
    const t = useTranslations("Header");
+   const localeNames = useLocaleNames();

    return (
      <header>
        <div className="...">
          {/* ... */}
          <div>
-           <LocaleSwitcher />
+           {/* Pass translated values to
+               the Client Component */}
+           <LocaleSwitcher localeNames={localeNames} />
          </div>
        </div>
      </header>
    );
  }Code language: Diff (diff)

Of course, we need to accept and use the localeNames prop in our LocaleSwitcher.

// app/_components/LocaleSwitcher.tsx

"use client";

// ...
- import { localeNames, locales } from "../../i18nconfig";
+ import { locales } from "../../i18nconfig";
+ import { Locale } from "../types";

  export default function LocaleSwitcher({
+   localeNames,
+ }: {
+   localeNames: Record<Locale, string>;
+ }) {
    // ...
    return (
      {/* ... */}
	    <select>
        {locales.map((locale) => (
          <option key={locale} value={locale}>
            {localeNames[locale]}
          </option>
        ))}
      </select>
      {/* ... */}
    )
  }Code language: Diff (diff)

In this case, the localeNames prop is a drop-in replacement for the one we were importing from our config, so we don’t need to change our component implementation to use the new prop.

With this, we’ve passed the localized strings (or localized record in this case) from our Server Component to a nested Client Component, keeping our i18n entirely on the server.

Our locale names are now localized | Phrase

How do I localize async components?

If we were to pull useTranslations() into our home page right now, we would get an error.

// app/[locale]/page.tsx

+ import { useTranslations } from "next-intl";
  // ...

  export default async function Home() {
+   // ⛔️ `useTranslations` in an async component
+   // will throw an error.
+   const t = useTranslations("Home");

    // ...
  );
}Code language: Diff (diff)

next-intl throws this error:

Unhandled Runtime Error

Error: `useTranslations` is not callable within an async component.
To resolve this, you can split your component into two, leaving the
async code in the first one and moving the usage of `useTranslations`
to the second one.Code language: plaintext (plaintext)

We can certainly take next-intl’s advice here, breaking up our component into an async component and a regular component. We’ll do this for our article page shortly. For our home page, let’s instead use the getTranslator() function from the new next-intl API, which works fine with async components.

// app/[locale]/page.tsx

+ import { getTranslator } from "next-intl/server";
  // ...
- import { ApiData } from "../types";
+ import { ApiData, Locale } from "../types";

+ // getTranslator() needs to take in the 
+ // current locale, so we use the route
+ // param to get it.
- export default async function Home(
+ export default async function Home({
+   params: { locale },
+ }: {
+   params: { locale: Locale };
+ }) {

+   // Note that getTranslator() is an async
+   // function itself.
+   const t = await getTranslator(locale, "Home");

  // ...

    return (
      <main>
        <div className="...">
          <div className="...">
            <div className="...">
              <span className="...">
-               Featured story
+               {/* Use t() as usual */}
+               {t("featured")}
              </span>
            </div>
            {/* ... */}
          </div>

          {/* ... */}
        </div>
      </main>
    );
}Code language: Diff (diff)

With this, we get no errors and the app works fine. getTranslator() is aware of the next-intl config in our i18n.ts file, so it has access to the locale translations.

Now let’s try taking next-intl’s advice as we move our attention to the article page, another async component. Here, we’ll break up the component into two: an async one that will fetch our data, and a regular one that handles our localization with useTranlsations().

// app/[locale]/articles/[id]/page.tsx

import { Article, Locale } from "@/app/types";
import { useLocale, useTranslations } from "next-intl";

// This new component is synchronous, so we can
// call `useTranslations()` in it without error.
function ArticlePage({ article }: { article: Article }) {
  const locale = useLocale() as Locale;
  const t = useTranslations("ArticlePage");

  const { author, publishedAt, sourceUrl } = article;
  const { title, body } = article.translations[locale];

  return (
    <main>
      <h1>{title}</h1>
      <div className="...">
        <div className="...">

          {/* Use t() as normal. */}
          <h3>{t("articleInfo")}</h3>

        </div>
        {/* ... */}
      </div>
      {/* ... */}
    </main>
  );
}

// Our exported component is asynchronous, so 
// it does our data fetching and passes its 
// data to the synchronous component above.
export default async function ArticlePageAsync({
  params: { id },
}: {
  params: { id: string };
}) {
  const article: Article = await fetch(`http://127.0.0.1/${id}`, {
    next: { revalidate: 10 },
  }).then((res) => res.json());

  return <ArticlePage article={article} />;
}Code language: JavaScript (javascript)

So, when localizing async components, you can use the new API functions, like getTranslator(), or break up your components into async and non-async parts. The choice is yours.

How do I ensure static rendering?

Next.js automatically manages the rendering strategy per component, defaulting to static rendering when it can. If it sees a dynamic function in a component, like a fetch request, it automatically switches its strategy to dynamic rendering.

Static rendering is good for performance because it allows the whole component to be cached, so we want to utilize it when we can.

However, next-intl will turn all of our components dynamic by default. This is clear when we run npm run build to create a production version of our app:

Dynamic pages | Phrase

Fortunately, next-intl does provide a workaround to get static rendering back. Let’s go through it. First, we’ll need to tell Next.js all the possible values of the [locale] route param so that it can resolve the param during static builds. We do this via generateStaticParams().

// app/[locale]/layout.tsx

+ import { locales } from "@/i18nconfig";
  // ...
  import { Locale } from "../types";

+ export function generateStaticParams() {
+   // `locales` is just an array of all of
+   // our supported locales: `["en-US", "ar-EG"]`
+   return locales.map((locale) => ({ locale }));
+ }

  // ...

  export default function RootLayout({
    children,
    params,
  }: {
    children: React.ReactNode;
    params: { locale: Locale };
  }) {
	  // ...
  }Code language: Diff (diff)

That takes care of the [locale] param. However, if we were to run npm run build now, next-intl would throw an error telling us that it only allows dynamic rendering.

As a stopgap, the library gives us a function to add to our static components: unstable_setRequestLocale(locale). This function takes the locale route param for each rendered page and ensures the page can be rendered statically at build time. Let’s use it in our layout and a couple of our pages.

// app/[locale]/layout.tsx

import { Locale } from "../types";
import { locales } from "@/i18nconfig";
+ import { unstable_setRequestLocale } from "next-intl/server";
// ...

export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

// ...

export default function RootLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: { locale: Locale };
}) {
  // ...

+ // Ensures static rendering at build time.
+ unstable_setRequestLocale(locale);

  return (
    // ...
  );
}Code language: Diff (diff)

Heads up » Next.js can render layouts and pages separately, so it’s a good idea to include unstable_setRequestLocale() in both our layouts and our pages.

// app/[locale]/page.tsx

- import { getTranslator } from "next-intl/server";
+ import { getTranslator, unstable_setRequestLocale } from "next-intl/server";
  // ...
  import { ApiData, Locale } from "../types";

  export default async function Home({
    params: { locale },
  }: {
    params: { locale: Locale };
  }) {
+   // Ensures static rendering at build time.
+   unstable_setRequestLocale(locale);
    const t = await getTranslator(locale, "Home");

    // ...
    );
  }Code language: Diff (diff)

Our home page will now render statically. Let’s make sure our About page does so as well.

// app/[locale]/about/page.tsx

+ import { Locale } from "@/app/types";
+ import { unstable_setRequestLocale } from "next-intl/server";

- export default function AboutPage() {
+ export default function AboutPage({
+   params: { locale },
+ }: {
+   params: { locale: Locale };
+ }) {

+  // Ensures static rendering at build time.
+  unstable_setRequestLocale(locale);

   return (
     // ...
   );
 }Code language: Diff (diff)

With that in place, our home and about pages will render as static components at build time. We can verify this by running npm run build.

Static rendering | Phrase

✋ Heads up » Server components with next-intl are statically generated but not statically exported; only Client Components support static exports with next-intl.

The article page remains a cached dynamic component. If you wanted, you could provide all possible [id]s via generateStaticParams() in the article page component to make that route static as well.

💡 Learn More » “Unstable” in unstable_setRequestLocale() denotes a temporary next-intl solution, which will be replaced by React’s upcoming createServerContext. More info is in next-intl’s documentation.

How do I navigate using localized links?

This one’s easy. next-intl provides a drop-in replacement for Next.js’ <Link> component. We can simply use next-intl’s to get localized links.

// app/_components/Header.tsx
import { useTranslations } from "next-intl";
- import Link from "next/Link";
+ import Link from "next-intl/link";
// ...

export default function Header() {
  const t = useTranslations("Header");
  // ...

  return (
    <header>
      <div className="...">
        <div className="...">
          {/* ... */}
+         {/* Use <Link> here as normal. */}
          <Link href="/about" className="-mt-1 ms-6">
            {t("nav.about")}
          </Link>
        </div>
        {/* ... */}
      </div>
    </header>
  );
}Code language: Diff (diff)

Now instead of going to /about, our link will point to /en-US/about when the active locale is English and /ar-EG/about when the active locale is Arabic.

🗒️ Note » We covered programmatic localized navigation in the language switcher section above. Just use next-intl’s router e.g. router.push("/about"). Keep in mind that programmatic navigation only works in Client Components.

🔗 Resource » Check out next-intl’s documentation on navigation for more info.

How do I work with right-to-left languages?

Arabic, Hebrew, Maldivian, and other languages are laid out right-to-left (rtl). Most others are left-to-right (ltr). Web browsers accommodate this through the <html dir="rtl"> attribute, which sets the layout of the whole page.

We’ll write a little custom hook to handle text direction. First, let’s add the TypeScript types for locale directions:

// app/types.ts

 export type Locale = "en-US" | "ar-EG";

+ export type LocaleDirection = "ltr" | "rtl";

// ...Code language: Diff (diff)

Next, we’ll install a little utility called rtl-detect, which returns the text direction of a given locale.

npm install rtl-detect
npm install --save-dev @types/rtl-detectCode language: CSS (css)

We’re ready to write our custom hook.

// app/_hooks/useTextDirection.ts

import { useLocale } from "next-intl";
import { isRtlLang } from "rtl-detect";
import { LocaleDirection } from "../types";

export default function useTextDirection(): LocaleDirection {
  const locale = useLocale();
  return isRtlLang(locale) ? "rtl" : "ltr";
}Code language: TypeScript (typescript)

Let’s pull this hook into our layout to set our document direction.

// app/[locale]/layout.tsx

  // ...
  import Footer from "../_components/Footer";
  import Header from "../_components/Header";
+ import useTextDirection from "../_hooks/useTextDirection";
  // ...
  import { Locale } from "../types";

  // ...

  export default function RootLayout({
    children,
    params: { locale },
  }: {
    children: React.ReactNode;
    params: { locale: Locale };
  }) {
+   const dir = useTextDirection();

    // ...

    return (
-     <html lang={locale}>
+     <html lang={locale} dir={dir}>
        <body>
          <Header />
          {children}
          <Footer />
        </body>
      </html>
    );
  }Code language: Diff (diff)

Now our html tag will look like <html dir=”ltr”> when the active locale is English, and <html dir=”rtl”> when the active locale is Arabic.

Our Arabic home page laid out right to left | Phrase

🤿 Go deeper » The browser manages the page’s right-to-left (rtl) layout, but site-specific elements need manual adjustment. More details are in the demo app (see the GitHub repo’s direction diff). Tailwind users can also explore logical properties.

Displaying localized content from the API

We have Arabic article data loading from our mock API, but we’re not displaying it. Let’s do so now.

// app/_components/FeaturedArticle.tsx

import { useLocale, useTranslations } from "next-intl";
// ...
import { Article, Locale } from "../types";

export default function FeaturedArticle({ article }: { article: Article }) {
  const locale = useLocale() as Locale;
  const t = useTranslations("FeaturedArticle");
  const { imgUrl, author, publishedAt } = article;
- const { title } = article.translations["en-US"];
+ const { title } = article.translations[locale];

  // ...
}Code language: Diff (diff)
// app/_components/ArticleTeaser.tsx

import { useLocale, useTranslations } from "next-intl";
// ...
import { Article, Locale } from "../types";

export default function ArticleTeaser({ article }: { article: Article }) {
  const locale = useLocale() as Locale;
  const t = useTranslations("ArticleTeaser");

  return (
    <article>
      <h2>
        <Link href={`/articles/${article.id}`}>
-         {article.translations["en-US"].title}
+         {article.translations[locale].title}
        </Link>
      </h2>
      {/* ... */}
    </article>
  );
}Code language: Diff (diff)
// app/[locale]/articles/[id]/page.tsx

import { Article, Locale } from "@/app/types";
import { useLocale, useTranslations } from "next-intl";

function ArticlePage({ article }: { article: Article }) {
  const locale = useLocale() as Locale;
  const t = useTranslations("ArticlePage");

  const { author, publishedAt, sourceUrl } = article;
- const { title, body } = article.translations["en-US"];
+ const { title, body } = article.translations[locale];

  return (
    // ...
  );
}

// ...Code language: Diff (diff)

Now we have some Arabic content to work with.

Our home page with Arabic articles | Phrase

How do I work with dynamic values in translation messages?

To inject runtime values in our translation, we can use the {variable} interpolation syntax. Let’s localize the author text to demonstrate.

// app/_translations/en-US.json

{
  // ...

+ "FeaturedArticle": {
+   "author": "by {author}"
+ }
}Code language: Diff (diff)
// app/_translations/ar-EG.json

{
  // ...

+ "FeaturedArticle": {
+   "author": "بقلم {author}"
+ }
}Code language: Diff (diff)

Now we just need to provide the author value when we call t().

// app/_components/FeaturedArticle.tsx

// ...

export default function FeaturedArticle({ article }: { article: Article }) {

  // ...

  return (
    <article >
      {/* ... */}
      <div className="...">
        {/* ... */}
        <div className="...">
-         <p>by {author}</p>
+         <p>{t("author", { author: article.author })}</p>
          <p>{publishedAt}</p>
        </div>
      </div>
    </article>
  );
}Code language: Diff (diff)

Our translation will render with the author value swapped in.

interpolation | Phrase

How do I work with localized plurals?

Different languages have different plural forms. While English has two plural forms, one and other, other languages can have more. Arabic, for example, has six.

🔗 Resource » The CLDR Language Plural Rules chart is a canonical source for languages’ plural forms.

🗒️ Note » next-intl uses the standard ICU Message Format along with the format’s powerful plural syntax. To learn more about ICU, read The Missing Guide to the ICU Message Format.

Let’s pluralize the article counter on our home page to demonstrate.

// app/_translations/en-US.json
{
  // ...
  "Home": {
    "featured": "Featured story",
    "feed": "The grapevine",
+   // This special syntax is how plurals are
+   // defined in ICU messages.
+   "feedCount": "{count, plural, one {# story} other {# stories}}"
  },
  // ...
}Code language: Diff (diff)
// app/_translations/ar-EG.json
{
  // ...
  "Home": {
    "featured": "قصة مميزة",
    "feed": "أخر الأخبار",
+   "feedCount": "{count, plural, zero {لا توجد قصص} one {قصة واحدة} two {قصتين ٢} few {# قصص} many {# قصة} other {# قصة}}"
  },
  // ...
}Code language: Diff (diff)

We need to supply the count variable when we call t(), as this will make next-intl select the correct plural form from the translation.

// app/[locale]/page.tsx

// ...
export default async function Home(...) {
  // ...

  return (
    <main>
      <div className="...">
        {/* ... */}

        <div className="...">
          <div className="...">
            <span className="...">{t("feed")}</span>
            <span className="...">
-             {feed.length} stories
+             {t("feedCount", { count: feed.length })}
            </span>
          </div>
          {/* ... */}
        </div>
      </div>
    </main>
  );
}Code language: Diff (diff)

With this in place, the story counter will display the correct plural form for the active locale and the number of stories.

The two English plural forms | Phrase

The six Arabic plural forms | Phrase

How do I localize numbers?

Not every region uses Western Arabic numerals (1, 2, 3). For instance, Tamil employs the Tamil numeral system (௦, ௧, ௨, ௩). Symbols for currency and large number separators vary by locale: In India, numbers are often separated by lakhs and crores (1,00,000 and 1,00,00,000) instead of thousands and millions (1,000 and 1,000,000).

✋ Heads up » Numbers and dates are often region-specific not language-specific, which is why it’s important to use region-specific locale codes. Use en-US, not en, for example.

🤿 Go deeper » Our guide to number localization goes into the nuances of localized number formatting.

So how do we untangle all this? As luck would have it, next-intl can do the heavy lifting for us with its formatters.

// In our components
import { useFormatter } from "next-intl";

export default function ComponentWithNumbers() {
  const format = useFormatter();

  return (
    <main>
      <p>{format.number(1200.99)}</p>
      <p>
        {format.number(1200.99, { style: "currency", currency: "USD" })}
      </p>
      <p>
        {format.number(0.26, { style: "percent" })}
      </p>
    </main>
  );
}Code language: TypeScript (typescript)

format.number() observes the formatting rules of the active locale. In the second and third examples above, we provide a map with formatting options that next-intl passes to the Intl.NumberFormat constructor (which is called under the hood).

Number formats rendered in the English-USA and Arabic-Egypt locales | Phrase

💡 Learn more » You can format numbers inside of translation messages with next-intl, as well as provide default global number formats for your whole app.

How do I localize dates?

Date formatting also varies across regions. For instance, the format often used in the United States is MM/DD/YYYY, whereas many European countries use DD/MM/YYYY. Such date representations, including the number systems used within dates, can vary significantly based on locale.

🗒️ Note » Localizing dates is similar to localizing numbers, so we encourage you to read the previous section if you haven’t already.

next-intl has built-in date formatters that help a lot with date formatting. We’ll use those to update our app shortly.

Use a global time zone

We often forget that Next.js is a full-stack framework until we see that dreaded hydration error — caused by a rendering discrepancy between the server and client. To avoid these issues when working with dates, it’s a good idea to configure a time zone that’s used for each request.

// i18n.ts

  import { getRequestConfig } from "next-intl/server";

  export default getRequestConfig(async ({ locale }) => ({
    messages: (await import(`./app/_translations/${locale}.json`)).default,
+   timeZone: "Africa/Cairo",
  }));Code language: Diff (diff)

Now let’s localize the dates in our demo app.

// app/_components/FeaturedArticle.tsx

- import { useLocale, useTranslations } from "next-intl";
+ import { useFormatter, useLocale, useTranslations } from "next-intl";
  // ...
  import { Article, Locale } from "../types";

  export default function FeaturedArticle({ article }: { article: Article }) {
    const locale = useLocale() as Locale;
    const t = useTranslations("FeaturedArticle");
+   const format = useFormatter();
    const { imgUrl, author, publishedAt } = article;
    // ...

    return (
      <article>
        {/* ... */}
        <div className="...">
          {/* ... */}
          <div className="...">
            {/* ... */}
            <p>
-             {publishedAt}
+             {format.dateTime(Date.parse(publishedAt), {
+               year: "numeric",
+               month: "short",
+               day: "numeric",
+             })}
            </p>
          </div>
        </div>
      </article>
  );
}Code language: Diff (diff)

Much like format.number(), format.dateTime() is locale-aware and displays the given date according to the formatting rules of the active locale. The optional second argument to format.dateTime() is a map that’s passed to the Intl.DateTimeFormat constructor (again, used by next-intl for date formatting under the hood).

Our home page displays dates localized for English-USA | Phrase

Our home page displays dates localized for Arabic-Egypt | Phrase

💡 Learn more » next-intl allows formatting datetimes inside of translation messages as well as formatting relative datetimes, e.g., “2 hours ago.”

How do I include HTML in my translation messages?

Different languages have different grammers, so it’s often wise to leave internal links or style emphases (italics, bold) for translators to place. However, we don’t want translators to worry about the intricacies of HTML.

Thankfully next-intl solves this with its t.rich() function, which can map custom tags to JSX. Let’s use it to localize our footer.

First, we’ll add translation messages with custom XML-like tags that denote where we want our HTML to go in the final render. We can name our tags anything we want.

// app/_translations/en-US.json
  {
    // ...

+   "Footer": {
+     "footer": "news source: <euronewsLink>euronews.green</euronewsLink>; created with <nextLink>next.js</nextLink>"
+   }
  }Code language: Diff (diff)
// app/_translations/ar-EG.json
  {
    // ...

+   "Footer": {
+     "footer": "مصدر الأخبار: <euronewsLink>يورو نيوز جرين</euronewsLink> - تم تطويره باستخدام  <nextLink>نكست چي إس</nextLink>"
+   }
  }Code language: Diff (diff)

Next, we’ll use t.rich(), passing it a translation key and a map of functions that resolve our JSX.

// app/_components/Footer.tsx

import { useTranslations } from "next-intl";

export default function Footer() {
  const t = useTranslations("Footer");

  return (
    <footer>
      <p>
        {t.rich("footer", {
          euronewsLink: (chunks) => (
            <a
              href="https://www.euronews.com/green/2023/08/07/here-are-all-the-positive-environmental-stories-from-2023-so-far"
            >
              {chunks}
            </a>
          ),
          nextLink: (chunks) => (
            <a href="https://nextjs.org/">
              {chunks}
            </a>
          ),
        })}
      </p>
    </footer>
  );
}Code language: JavaScript (javascript)

Our footer translated to English | Phrase

Our footer translated to Arabic | Phrase

✋ Heads up » When pulled from getTranslator(), t.rich() can only have strings returned from its chunks functions, not JSX. If you’re localizing async components, consider pulling t.rich() from useTranslations() and splitting your components. (The earlier section on localizing async components covers component splitting).

🔗 Resource » Check out the official docs for more info on rich text formatting.

How do I localize page metadata?

Since metadata is managed outside of component rendering, we must utilize next-intl’s getTranslator() function to localize it.

As always, we add our translation messages first.

// app/_translations/en-US.json

  {
    // ...
+   "Metadata": {
+     "title": "Dirkha - Good Green News",
+     "description": "Built with Next.js and next-intl"
+   }, 
    // ...
  }Code language: Diff (diff)
// app/_translations/ar-EG.json

  {
    // ...
+   "Metadata": {
+     "title": "دِرخَة - أخبار بيئية إيجابية",
+     "description": "تم إنشاؤه باستخدام Next.js و next-intl"
+   }, 
    // ...
  }Code language: Diff (diff)

Now we can pull these translations in our root layout’s generateMetadata() function, which is used by Next.js.

// app/[locale]/layout.tsx

  // ...
- import { unstable_setRequestLocale } from "next-intl/server";
+ import { getTranslator, unstable_setRequestLocale } from "next-intl/server";
  //...
  import { Locale } from "../types";

  // ...

- export const metadata: Metadata = {
-   title: "Dirkha - Good Green News",
-   description: "Created using Next.js",
- };
+ export async function generateMetadata({
+   params: { locale },
+ }: {
+   params: { locale: Locale };
+ }) {
+   const t = await getTranslator(locale, "Metadata");
+   return {
+     title: t("title"),
+     description: t("description"),
+   };
+ }

  export default function RootLayout({
    children,
    params: { locale },
  }: {
    children: React.ReactNode;
    params: { locale: Locale };
  }) {
    // ...
  }Code language: Diff (diff)

Remember that while getTranslator() has access to our config from i18n.ts, it needs to be given the active locale, which we grab from our route params ([locale]).

With that, our pages now show localized metadata.

The home page’s metadata translated to Arabic | Phrase

The home page’s metadata translated to English | Phrase

Of course, we can use the same recipe above to override the metadata in each page.

🔗 Resource » More info about localizing meta can be found on the Internationalization of Metadata & Route Handlers in Next.js 13 page of the next-intl docs.

How do I make my message keys type-safe?

If we want to make TypeScript catch our t("nonexistent_key") calls at compile time, we can add a global.d.ts to our project (or amend our existing one).

// global.d.ts

type Messages = typeof import("./app/_translations/en-US.json");
declare interface IntlMessages extends Messages {}Code language: TypeScript (typescript)

We’re assuming that en-US is the source locale for translations here. Now if we try to use incorrect keys, TypeScript will complain in our code editor.

The TypeScript compiler catching an instance of using the t() function with the wrong key | Phrase

✋ Heads up » This will only work with useTranslations(), not getTranslator(), which is another reason to split your async components. (See the earlier section on async components).

That about does it for this guide. Here’s a look at our final localized app.

Final app

🔗 Resource » You can get the full code of the localized demo app from our GitHub repo.

Push the boundaries of React localization

We hope you’ve found this guide to localizing Next.js with the App Router and next-intl library helpful. We’ll keep it updated as Next.js and next-intl continue to grow.

When you’re all set to start the translation process, rely on Phrase Strings to handle the heavy lifting. A specialized software localization platform, Phrase Strings comes with dozens of tools designed to automate your translation process and native integrations with platforms like GitHub, GitLab, and Bitbucket.

With its user-friendly strings editor, translators can effortlessly access your content and transfer it into your target languages. Once your translations are set, easily integrate them back into your project with a single command or automated sync.

This way, you can stay focused on what you love—your code. Sign up for a free trial and see for yourself why developers appreciate using Phrase Strings for software localization.

String Management UI visual | Phrase

Phrase Strings

Take your web or mobile app global without any hassle

Adapt your software, website, or video game for global audiences with the leanest and most realiable software localization platform.

Explore Phrase Strings