Internationalization In Next.js 13 With React Server Components

About The Author

Jan is a user interface engineer from Austria with a specialization in building React apps. In between projects, he’s a guest lecturer at FHV and works on … More about Jan ↬

Email Newsletter

Weekly tips on front-end & UX.
Trusted by 200,000+ folks.

Based on an example of a multilingual app that displays street photography images from Unsplash, Jan Amann explores next-intl to implement all internationalization needs in React Server Components and shares a technique for introducing interactivity with a minimalistic client-side footprint.

With the introduction of Next.js 13 and the beta release of the App Router, React Server Components became publicly available. This new paradigm allows components that don’t require React’s interactive features, such as useState and useEffect, to remain server-side only.

One area that benefits from this new capability is internationalization. Traditionally, internationalization requires a tradeoff in performance as loading translations results in larger client-side bundles and using message parsers impacts the client runtime performance of your app.

The promise of React Server Components is that we can have our cake and eat it too. If internationalization is implemented entirely on the server side, we can achieve new levels of performance for our apps, leaving the client side for interactive features. But how can we work with this paradigm when we need interactively-controlled states that should be reflected in internationalized messages?

In this article, we’ll explore a multilingual app that displays street photography images from Unsplash. We’ll use next-intl to implement all our internationalization needs in React Server Components, and we’ll look at a technique for introducing interactivity with a minimalistic client-side footprint.

App final framed
You can also check the interactive demo. (Large preview)

Fetching Photos From Unsplash

A key benefit of Server Components is the ability to fetch data directly from inside components via async/await. We can use this to fetch the photos from Unsplash in our page component.

But first, we need to create our API client based on the official Unsplash SDK.

import {createApi} from 'unsplash-js';

export default createApi({
  accessKey: process.env.UNSPLASH_ACCESS_KEY
});

Once we have our Unsplash API client, we can use it in our page component.

import {OrderBy} from 'unsplash-js';
import UnsplashApiClient from './UnsplashApiClient';

export default async function Index() {
  const topicSlug = 'street-photography';

  const [topicRequest, photosRequest] = await Promise.all([
    UnsplashApiClient.topics.get({topicIdOrSlug: topicSlug}),
    UnsplashApiClient.topics.getPhotos({
      topicIdOrSlug: topicSlug,
      perPage: 4
    })
  ]);

  return (
    <PhotoViewer
      coverPhoto={topicRequest.response.cover_photo}
      photos={photosRequest.response.results}
    />
  );
}

Note: We use Promise.all to invoke both requests that we need to make in parallel. This way, we avoid a request waterfall.

At this point, our app renders a simple photo grid.

An app which renders a simple photo grid
(Large preview)

The app currently uses hard-coded English labels, and the dates of the photos are displayed as timestamps, which is not very user-friendly (yet).

Adding Internationalization With next-intl

In addition to English, we’d like our app to be available in Spanish. Support for Server Components is currently in beta for next-intl, so we can use the installation instructions for the latest beta to set up our app for internationalization.

Formatting Dates

Aside from adding a second language, we’ve already found that the app doesn’t adapt well to English users because the dates should be formatted. To achieve a good user experience, we’d like to tell the user the relative time when the photo was uploaded (e.g., “8 days ago”).

Once next-intl is set up, we can fix the formatting by using the format.relativeTime function in the component that renders each photo.

import {useFormatter} from 'next-intl';

export default function PhotoGridItem({photo}) {
  const format = useFormatter();
  const updatedAt = new Date(photo.updated_at);

  return (
    <a href={photo.links.html}>
        {/* ... */}
        <p>{format.relativeTime(updatedAt)}</p>
      </div>
    </a>
  );
}

Now the date when a photo has been updated is easier to read.

An app’s photo time with the formatted date
(Large preview)

Hint: In a traditional React app that renders on both the server and client side, it can be quite a challenge to ensure that the displayed relative date is in sync across the server and client. Since these are different environments and may be in different time zones, you need to configure a mechanism to transfer the server time to the client side. By performing the formatting only on the server side, we don’t have to worry about this problem in the first place.

¡Hola! 👋 Translating Our App To Spanish

Next, we can replace the static labels in the header with localized messages. These labels are passed as props from the PhotoViewer component, so this is our chance to introduce dynamic labels via the useTranslations hook.

import {useTranslations} from 'next-intl';

export default function PhotoViewer(/* ... */) {
  const t = useTranslations('PhotoViewer');

  return (
    <>
      <Header
        title={t('title')}
        description={t('description')}
      />
      {/* ... */}
    </>
  );
}

For each internationalized label we add, we need to make sure that there is an appropriate entry set up for all languages.

// en.json
{
  "PhotoViewer": {
    "title": "Street photography",
    "description": "Street photography captures real-life moments and human interactions in public places. It is a way to tell visual stories and freeze fleeting moments of time, turning the ordinary into the extraordinary."
  }
}
// es.json
{
  "PhotoViewer": {
    "title": "Street photography",
    "description": "La fotografía callejera capta momentos de la vida real y interacciones humanas en lugares públicos. Es una forma de contar historias visuales y congelar momentos fugaces del tiempo, convirtiendo lo ordinario en lo extraordinario."
  }
}

Tip: next-intl provides a TypeScript integration that helps you ensure that you’re only referencing valid message keys.

Once this is done, we can visit the Spanish version of the app at /es.

The Spanish version of the app
(Large preview)

So far, so good!

Adding Interactivity: Dynamic Ordering Of Photos

By default, the Unsplash API returns the most popular photos. We want the user to be able to change the order to show the most recent photos first.

Here, the question arises whether we should resort to client-side data fetching so that we can implement this feature with useState. However, that would require us to move all of our components to the client side, resulting in an increased bundle size.

Do we have an alternative? Yes. And it’s a capability that has been around on the web for ages: search parameters (sometimes referred to as query parameters). What makes search parameters a great option for our use case is that they can be read on the server side.

So let’s modify our page component to receive searchParams via props.

export default async function Index({searchParams}) {
  const orderBy = searchParams.orderBy || OrderBy.POPULAR;

  const [/* ... */, photosRequest] = await Promise.all([
    /* ... */,
    UnsplashApiClient.topics.getPhotos({orderBy, /* ... */})
  ]);

After this change, the user can navigate to /?orderBy=latest to change the order of the displayed photos.

To make it easy for the user to change the value of the search parameter, we’d like to render an interactive select element from within a component.

The app’s order select with the most popular photos displayed
(Large preview)

We can mark the component with 'use client'; to attach an event handler and process change events from the select element. Nevertheless, we would like to keep the internationalization concerns on the server side to reduce the size of the client bundle.

Let’s have a look at the required markup for our select element.

<select>
  <option value="popular">Popular</option>
  <option value="latest">Latest</option>
</select>

We can split this markup into two parts:

  1. Render the select element with an interactive Client Component.
  2. Render the internationalized option elements with a Server Component and pass them as children to the select element.

Let’s implement the select element for the client side.

'use client';

import {useRouter} from 'next-intl/client';

export default function OrderBySelect({orderBy, children}) {
  const router = useRouter();

  function onChange(event) {
    // The `useRouter` hook from `next-intl` automatically
    // considers a potential locale prefix of the pathname.
    router.replace('/?orderBy=' + event.target.value);
  }

  return (
    <select defaultValue={orderBy} onChange={onChange}>
      {children}
    </select>
  );
}

Now, let’s use our component in PhotoViewer and provide the localized option elements as children.

import {useTranslations} from 'next-intl';
import OrderBySelect from './OrderBySelect';

export default function PhotoViewer({orderBy, /* ... */}) {
  const t = useTranslations('PhotoViewer');

  return (
    <>
      {/* ... */}
      <OrderBySelect orderBy={orderBy}>
        <option value="popular">{t('orderBy.popular')}</option>
        <option value="latest">{t('orderBy.latest')}</option>
      </OrderBySelect>
    </>
  );
}

With this pattern, the markup for the option elements is now generated on the server side and passed to the OrderBySelect, which handles the change event on the client side.

Tip: Since we have to wait for the updated markup to be generated on the server side when the order is changed, we may want to show the user a loading state. React 18 introduced the useTransition hook, which is integrated with Server Components. This allows us to disable the select element while waiting for a response from the server.

import {useRouter} from 'next-intl/client';
import {useTransition} from 'react';

export default function OrderBySelect({orderBy, children}) {
  const [isTransitioning, startTransition] = useTransition();
  const router = useRouter();

  function onChange(event) {
    startTransition(() => {
      router.replace('/?orderBy=' + event.target.value);
    });
  }

  return (
    <select disabled={isTransitioning} /* ... */>
      {children}
    </select>
  );
}

Adding More Interactivity: Page Controls

The same pattern that we’ve explored for changing the order can be applied to page controls by introducing a page search parameter.

App's pagination
(Large preview)

Note that languages have different rules for handling decimal and thousand separators. Furthermore, languages have different forms of pluralization: while English only makes a grammatical distinction between one and zero/many elements, for example, Croatian has a separate form for ‘few’ elements.

next-intl uses the ICU syntax which makes it possible to express these language subtleties.

// en.json
{
  "Pagination": {
    "info": "Page {page, number} of {totalPages, number} ({totalElements, plural, =1 {one result} other {# results}} in total)",
    // ...
  }
}

This time we don’t need to mark a component with 'use client';. Instead, we can implement this with regular anchor tags.

import {ArrowLeftIcon, ArrowRightIcon} from '@heroicons/react/24/solid';
import {Link, useTranslations} from 'next-intl';

export default function Pagination({pageInfo, orderBy}) {
  const t = useTranslations('Pagination');
  const totalPages = Math.ceil(pageInfo.totalElements / pageInfo.size);

  function getHref(page) {
    return {
      // Since we're using `Link` from next-intl, a potential locale
      // prefix of the pathname is automatically considered.
      pathname: '/',
      // Keep a potentially existing `orderBy` parameter. 
      query: {orderBy, page}
    };
  }

  return (
    <>
      {pageInfo.page > 1 && (
        <Link aria-label={t('prev')} href={getHref(pageInfo.page - 1)}>
          <ArrowLeftIcon />
        </Link>
      )}
      <p>{t('info', {...pageInfo, totalPages})}</p>
      {pageInfo.page < totalPages && (
        <Link aria-label={t('prev')} href={getHref(pageInfo.page + 1)}>
          <ArrowRightIcon />
        </Link>
      )}
    </>
  );
}

Conclusion

Server Components Are A Great Match For Internationalization

Internationalization is an important part of the user experience, whether you support multiple languages or you want to get the subtleties of a single language right. A library like next-intl can help with both cases.

Implementing internationalization in Next.js apps has historically come with a performance tradeoff, but with Server Components, this is no longer the case. However, it might take some time to explore and learn patterns that will help you keep your internationalization concerns on the server side.

In our street photography viewer app, we only needed to move a single component to the client side: OrderBySelect.

App’s components
(Large preview)

Another aspect to note is that you might want to consider implementing loading states since the network latency introduces a delay before your users see the result of their actions.

Search Parameters Are A Great Alternative To useState

Search parameters are a great way to implement interactive features in Next.js apps, as they help to reduce the bundle size of the client side.

Apart from performance, there are other benefits of using search parameters:

  • URLs with search parameters can be shared while preserving the application state.
  • Bookmarks preserve the state as well.
  • You can optionally integrate with the browser history, enabling undoing state changes via the back button.

Note, however, that there are also tradeoffs to consider:

  • Search parameter values are strings, so you may need to serialize and deserialize data types.
  • The URL is part of the user interface, so using many search parameters may affect readability.

You can have a look at the complete code of the example on GitHub.

Many thanks to Delba de Oliveira from Vercel for providing feedback for this article!

Further Reading On SmashingMag

Smashing Editorial (yk, il)