Skip to content
next-intl 3.0 is out! (announcement)
Blog
How (not) to use translations outside of React components

How (not) to use translations outside of React components

Apr 21, 2023 · by Jan Amann

Have you ever wondered why next-intl doesn’t provide an API to consume translations outside of React components?

The traditional way to internationalize your app with next-intl is to use the useTranslations hook:

import {useTranslations} from 'next-intl';
 
function About() {
  const t = useTranslations('About');
  return <h1>{t('title')}</h1>;
}

Why is it not possible to format messages e.g. in utility functions? Is there something missing here?

This may seem like an unnecessary limitation, but the absence of this feature is intentional and aims to encourage the use of proven patterns that avoid potential issues—especially if they are easy to overlook.

Example: Formatting error messages

Let's assume you have a FeedbackForm component that posts user feedback to a backend endpoint. Unfortunately, the server occasionally returns a 504 status code due to the high volume of feedback. To improve the user experience, you would like to implement automatic retries and provide appropriate feedback to the user.

Here’s a naive approach. Can you spot an issue with this implementation?

import {useTranslations, useNow} from 'next-intl';
import {addMinutes} from 'date-fns';
 
function sendFeedback() {
  // ❌ Bad implementation: Returns formatted messages
  API.sendFeedback().catch((error) => {
    // In case of a gateway timeout, notify the
    // user that we'll try again in 5 minutes
    if (error.status === 504) {
      // (let's assume `t` is defined here for the sake of the example)
      return t('timeout', {nextAttempt: addMinutes(new Date(), 5)});
    }
  });
}
 
function FeedbackForm({user}) {
  const t = useTranslations('Form');
  const [errorMessage, setErrorMessage] = useState();
 
  function onSubmit() {
    sendFeedback().catch((errorMessage) => {
      setErrorMessage(errorMessage);
    });
  }
 
  return (
    <form onSubmit={onSubmit}>
      {errorMessage != null && <p>{errorMessage}</p>}
      ...
    </form>
  );
}

Have you found an issue?

Let's have a look together:

  1. The nextAttempt value is interpolated into the message in a utility function that is called by an event handler. There's no way how we can keep the remaining time updated as we're nearing the retry timeout.
  2. If the user changes the language, the error message will remain in the previously selected language, leading to a jarring user experience.

A better way: Formatting during render

To avoid these issues, we can format messages during the rendering phase of React, turning data structures into human readable strings.

import {useTranslations, useNow} from 'next-intl';
import {addMinutes} from 'date-fns';
 
function FeedbackForm({user}) {
  const t = useTranslations('Form');
  const [retry, setRetry] = useState();
  const now = useNow({
    // Update every minute
    updateInterval: 1000 * 60
  });
 
  function onSubmit() {
    // ✅ Good implementation: Store data structures in state
    API.sendFeedback().catch((error) => {
      if (error.status === 504) {
        setRetry(addMinutes(now, 5));
      }
    });
  }
 
  return (
    <form onSubmit={onSubmit}>
      {retry != null && <p>{t('timeout', {nextAttempt: nextAttempt - now})}</p>}
      ...
    </form>
  );
}

Now, we can offer a better user experience by interactively counting down the time to the next attempt.

Additionally, this approach is more robust to possibly unexpected states, like the user changing the language while the timeout message is being displayed.

The exception that proves the rule

If you’re working with Next.js, you might want to translate i18n messages in API routes (opens in a new tab), Route Handlers (opens in a new tab) or the Metadata API (opens in a new tab).

next-intl/server provides a set of awaitable versions of the functions that you usually call as hooks from within components. These are agnostic from React and can be used for these cases.

import {getTranslations} from 'next-intl/server';
 
// The `locale` is received from Next.js via `params`
const locale = params.locale;
 
// This creates the same function that is returned by `useTranslations`.
const t = await getTranslations({locale});
 
// Result: "Hello world!"
t('hello', {name: 'world'});

This seems familiar

If you’ve been working with React for a longer time, you might have experienced the change from component{DidMount,DidUpdate,WillUnmount} to useEffect (opens in a new tab). The reason why useEffect is superior is because it nudges the developer into a direction where the app is always in sync and by doing this, a whole array of potential issues just magically disappear.

By limiting ourselves to only format messages during render, we're in a similar situation: The rendered output of translated messages is always in sync with app state and we can rely on the app being consistent.

Related: "How can I reuse messages?" in the structuring messages docs