Server-Side i18n With Next.js 13 And next-intl

The App Router in Next.js 13 made React Server Components publicly available. These components run exclusively on the server, skipping the client-side JavaScript bundle entirely for anything that doesn't need useState or useEffect. This has a direct consequence for internationalization (i18n): a workload that traditionally inflated client bundles now has a chance to live entirely on the server.

To see how this plays out, let's examine a multilingual street photography viewer that fetches images from the Unsplash API. The entire app's internationalization is handled with next-intl within Server Components. Interactive features are then layered in with a deliberate, minimal client-side footprint.

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

Fetching Data Directly In The Component Tree

One of the immediate benefits of Server Components is the ability to perform data fetching inside the component itself using async/await. With the Unsplash SDK wrapped in a client, the page component can issue parallel requests for the photo feed using Promise.all, which avoids sequential request waterfalls.

import {createApi} from 'unsplash-js';

export default createApi({
  accessKey: process.env.UNSPLASH_ACCESS_KEY
});
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}
    />
  );
}

This yields a simple grid of photos. However, the interface at this point is not polished: labels such as "Load more" and "Order by" are hard-coded English strings, while photo timestamps render as raw numbers.

Setting Up Localization With next-intl

The sample app supports both English and Spanish. next-intl provides installation instructions specifically for the Next.js Server Components beta. Once configured, hard-coded text can be replaced with localized messages pulled from a catalog.

Cleaning Up Raw Timestamps

The biggest usability problem is the presentation of dates. The app should communicate to the user the relative time since a photo was last updated, e.g., "8 days ago." This is accomplished with the format.relativeTime function available inside Server Components.

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>
  );
}

This removes a subtle headache common in client-rendered apps: synchronizing date output between the server and a potentially distant client time zone. By keeping formatting server-only, there's no risk of hydration mismatches.

Localizing Interface Strings

Header labels are passed as props from a parent PhotoViewer component. With the useTranslations hook, those labels become dynamic, pulling the appropriate strings from the message file for the active locale.

import {useTranslations} from 'next-intl';

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

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

Every new internationalized string requires parallel definitions in the message catalog for each supported language.

// 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."
  }
}

The next-intl TypeScript integration validates your usage, ensuring that the application only references defined message keys. After adding Spanish translations, a visit to the /es route displays the fully localized interface.

Interactivity Without Moving All The Code To The Client

The core feature request is a “sort by” control that lets users switch the photo order from the default "popular" to "latest." In a purely client-side world, this state would trigger new data fetching. However, that would force all related components into a client bundle, increasing its size.

The alternative is a carefully crafted markup split combined with search parameters such as orderBy:

  • The interactive part is the orderBy select element itself. It has an event handler and must live in a Client Component.
  • The localized option elements inside the select have no logic. They can be generated by a Server Component and passed to the select as children.

On the server side, the page component accepts the search parameter from its props and directly passes it to the API request call. This approach avoids relying on a client fetch on state change; instead, the navigation to the new URL triggers a server response with the newly ordered photos.

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

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

The client-side select component can now be kept small, only handling the user's change event and navigating to the corresponding path.

'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>
  );
}

The server markup for the options is passed into the select as children, leaving the internationalization data safely on the server.

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>
    </>
  );
}

Since latency is involved in this server round trip, the React 18 useTransition hook can be brought in to disable the select element while the new server markup is processed, preparing the user for the update in the interface.

Scaling To Page Controls

For pagination, the same searchParams pattern re-appears, introducing a page parameter that gets appended to the URL. For page navigation, however, a Client Component isn't necessary at all; regular HTML anchors accomplish this without adding any JavaScript to the client.

Localized page labels bring up complexities with pluralization. Languages like English differentiate between one item and several, while other languages, like Croatian, distinguish between a "few" items and many. next-intl addresses these differences by implementing the full ICU syntax in its message catalog, ensuring that the pluralized grammar generated matches the user's locale.

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

Costs And Benefits Of The Search-Parameter Approach

Using search parameters as a substitute for React state offers several advantages beyond keeping a smaller client bundle:

  • Sharing a URL preserves the exact visible state of the viewer.
  • Bookmarks load the page in the same order and position.
  • The browser's own history can make the back and forward buttons serve as undo and redo controls.

There are limitations however, chief among them being that URLs are text-only. Any state types non-string, like numbers, might require explicit serialization and deserialization. Also, since the query string is directly visible in the interface, squeezing too many features into it can hurt URL readability.

The tradeoff, though, reflects the broader paradigm shift: By keeping most of the application in Server Components, only the tiny OrderBySelect interactive element reaches the client within this viewer.

App’s components
(Large preview)