Internationalization In Next.js 13 With React Server Components
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.
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.
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 Withnext-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.
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
.
So far, so good!
Adding Interactivity: Dynamic Ordering Of PhotosBy 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.
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:
- Render the
select
element with an interactive Client Component. - Render the internationalized
option
elements with a Server Component and pass them aschildren
to theselect
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.
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>
)}
</>
);
}
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
.
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
- “Understanding App Directory Architecture In Next.js”, Atila Fassina
- “Designing For Users Across Cultures: An Interview With Jenny Shen”, Rachel Andrew
- “Dynamic Data-Fetching In An Authenticated Next.js App”, Caleb Olojo
- “How To Implement Search Functionality In Your Nuxt App Using Algolia InstantSearch”, Miracle Onyenma