Hybrid i18n with Next and Astro (part 2)

June 4, 2024 (last month)

Cover image

In this series, I relate my quest for the perfect internationalization system (i18n) for hybrid frameworks, namely Next.js and Astro. This story involves nasty technical concepts, but also teapots 🫖, so it's probably worth the read.

The end goal is to improve the Devographics translation system, which powers the State of JavaScript, CSS, HTML, React and GraphQL surveys. It is used both by the Next.js survey app and the new Astro results app which is not released yet.

You are reading the second part of this series. You can read part 1, "Tokens are all you need", here.

Part 3 "From tokens to token expressions" is out!

Part 2 : Server Components, we have a deeply nested problem

If you've been interested in JavaScript frameworks lately, you may have heard about Next.js App Router and React Server Components, or you may know about Astro.

Both are hybrid frameworks that allow to blend interactive client-side rendered components, and pure server-side rendered components. Let's focus on server components for translated texts.

Translations are leaves of a big tree

React Server Components and Astro components are ideal when it comes to rendering non-interactive content to HTML. Server components don't load any unused JavaScript, they don't consume client-side CPU, they appear instantly.

Perfect for displaying translated text. In theory. Because in practice, it's a bit more complicated.

Translation strings tend to be leaves in the tree of components that makes up a page. Because they are just text, they don't have children components.

A tree of components

The problem is that deeply nested server components are not super practical.

First we cannot use a traditional React context for server components. It makes it difficult to pass around the dictionary of translations, which is loaded higher up in the component tree, in a layout or at page-level.

Then, there are no elegant solutions to access dynamic parameters in Server Components, as discussed in many places for Next.js. Yet the current user locale ("en-US", "fr-FR") is usually represented as a dynamic route parameter within the URL.

We can still rely on "props drilling", but this means each and every component should accept a i18n prop, that's cumbersome.

function MyPage({params}) {
    const i18n = await loadLocale(params.locale)
    return (
    	<Layout i18n={i18n}>
            <Title i18n={i18n} />
            <AccordionContent i18n={i18n}>
                <ContentOne i18n={i18n} />
                <ContentTwo i18n={i18n} />
            </AccordionContent>
        </Layout>
    )
}

"What's your daily routine as a web developer?"

"Well I grab my morning coffee and then I spend 7 hours stamping i18n={i18n} on React components. That's a fulfilling job, I am very grateful."

A server context with React cache

Hopefully, we have better solutions than props drilling.

It turns out that React cache function, which allows to deduplicate function calls when rendering server components, can also be used as a server context. I've explored this idea early after Next 13 release, my goal was to properly cache GraphQL calls using a smart client like Apollo Client.

React cache can also be used to pass around page-level parameters, using a get/set pattern. You just need to return a mutable object from the cache function call. The server-only-context package wraps up this idea into a nice reusable helper.

// From server-only-context documenation
// https://www.npmjs.com/package/server-only-context
import serverContext from "server-only-context";

export const [getLocale, setLocale] = serverContext("en");
export const [getUserId, setUserId] = serverContext("");

Next.js "unstable_cache" is NOT the same thing. React "cache" is scoped to the current HTTP request, thanks to Node.js AsyncLocalStorage, which is what we want here. Next.js "unstable_cache" is not, it's not suited to be used as a server context during rendering.

This how I use this context in the Devographics codebase:

import serverContext from "server-only-context";

const [getLocale, setLocale] = serverContext<string | null>(null);

export const rscLocaleIdContext = () => {
  const locale = getLocale();
  if (!locale)
    throw new Error(
      "Calling getLocale from server-context before it is set.\
       Remember to set the locale from the current page params."
    );
  return locale;
};
export const setLocaleIdServerContext = setLocale;

I am adding a small check to detect if the context has been properly set in the page/layout. This leads to better type inference, a tip I already used with client-side React Context.

Some people are trying to figure better solutions. This discussion at next-intl is pretty insightful. This answer from Delba Oliveira explains why providing route params to RSC is difficult.

A server context with Astro.locals

In Astro, the Astro.locals object acts as a request-scoped server context. It can be set at page-level or even within a middleware.

const { locale: localeDict, error } = await getLocaleDict({
  localeId: locale,
  contexts: ["common", "results", surveyId, surveyId + "_" + edition.year],
});
if (error) throw error;
// This object is now available in all Astro component
Astro.locals.i18n = astroI18nCtx(localeDict);

It looks simpler than Next.js solution, because Astro.locals is a mutable object, so we don't have to rely on a cached function. The locals naming inherits from Express res.locals.

Why Next.js and React use such a convoluted approach when it's so simple in Astro? That's because using a mutable object doesn't automatically deduplicate concurrent data fetching calls, it's easier to handle with functions.

Server context is the perfect solution to store the current locale parameter as well as the translation dictionary.

Implementation in Devographics

The next-intl library supports React Server Components. The implementation is using React cache as described earlier, however it treats this idea as an unstable solution until a better API comes up.

It's great but we prefer to avoid dependencies when it comes to i18n, so we crafted our own little helper, rscTeapot.

Under the hood, it downloads the right dictionary depending on the current locale route parameter, caches it, and generates a t function that can get you the translated string for any token.

The getMessage function is a more elaborate helper that also returns metadata and can handle HTML content.

export async function rscTeapot({
  contexts,
}: { contexts?: Array<string> } = {}) {
  const { locale, error } = await rscLocaleCached({ contexts });
  if (error) return { error };
  const { t, getMessage } = makeTea(locale);
  return { t, getMessage };
}
// Usage within a React Server Component
const { t } = rscTeapot();
return <span>{t("home.title")}</span>; // Bonjour les amis

It's a little teapot 🫖 , it makes t🍵! It's so cute!

Speaking of naming, the rsc prefix is a trick I am quite proud of. It implies that 1. the function is safe to use in RSCs, it won't render private data and 2. it is properly memoized/cached. It's symmetrical to client-side hooks with use and makes it clear wether the function is meant for client or server components.

For Astro implementation is a bit simpler thanks to Astro.locals as explained earlier. We have a T.astro component for easy display.

---
// T.astro (simplified)
// This is a pure server component,
// the i18n object is NOT passed to the client
const { i18n } = Astro.locals;
const { token, values, html } = Astro.props;
const text = i18n.t(token, values);
---

<span>{text}</span>

Next step: token expressions

In this article, we got server components covered, both for Next.js/React Server Components and Astro components.

This seemed like the hard part, right? Well it's not. Client components are the hard part. We want to make sure we only send tokens to the browser that are actually used during client-side rendering.

The problem is that we can't even deal with this issue directly, because of dynamic tokens. Dynamic tokens rely on variables, for instance const text = t("home.title." + currentTab). This prevents static analysis and wrecks havoc for all tools that rely on a bundling step.

In the next article we are going to introduce a new language to express translation tokens, which is a prerequisite for an efficient token filtering system for client components.

Part 3 "From tokens to token expressions" is out!

Part 4 "Optimized client-side translations" is out!

A bientôt !

Loved this article?

You will probably also like my Next.js course,

"Efficient Internationalization for Next.js"

Want an excuse to learn the ins and outs of Next.js? Search no more, let's implement internationalization (i18n) together!