DEV Community

Thomas Ledoux
Thomas Ledoux

Posted on • Originally published at thomasledoux.be

A minimal dependency-free translation system for Next.js

TLDR:

Source code &
Live demo

Introduction

I've been doing some web speed performance checks on the Next.js websites we are building at my current company lately, and one of the things that I noticed is that the translation system we are using is adding a lot of weight to the bundle size. I decided to create a minimal translation system that doesn't add any dependencies to the bundle size.
Of course this is no shade to the existing translation systems out there, but I wanted to see if I could create something that is minimal and dependency-free, without all the functionality which systems like next-intl provide.

Setting up the backend

For the purpose of this blog and demo I decided to use POEditor to host my translations.
They have a generous free tier which is more than enough for this demo.
I created a project, added 2 languages (NL and EN) and added a few translations to it.

Screenshot of POEditor

Setting up the frontend

Getting started with a new Next.js app is easy, just run npx create-next-app, follow the steps in your terminal and you're good to go.
For this demo and blog I'm using the Pages Router, I might do another blog post on doing the same in the App Router later, but this might work a bit differently since React Context can not be used in RSC.
In next.config.js I added

i18n: {
  defaultLocale: 'en',
  locales: ['en', 'nl'],
},
Enter fullscreen mode Exit fullscreen mode

to add an extra language (Dutch) to the project.

Setting up the translation fetching from POEditor

Using the Fetch API, I'm fetching the translations for my project from POEditor in the specified language.
I'm creating an object with the translations, where the key is the translation key and the value is the translation itself.

export const getDictionaryItems = async (locale: string) => {
  const urlencoded = new URLSearchParams();
  urlencoded.append("id", process.env.POEDITOR_PROJECT_ID!);
  urlencoded.append("api_token", process.env.POEDITOR_API_TOKEN!);
  urlencoded.append("language", locale);
  const dictionaryItems = await fetch(
    "https://api.poeditor.com/v2/terms/list",
    {
      method: "POST",
      body: urlencoded,
    },
  ).then((res) => res.json());
  const terms = dictionaryItems.result.terms as {
    term: string;
    translation: {
      content: string;
    };
  }[];
  return Object.fromEntries(
    terms.map((el) => [el.term, el.translation.content]),
  );
};
Enter fullscreen mode Exit fullscreen mode

Setting up the translation context

Here I'm creating a React Context to store the translations.

import { createContext } from "react";

export const DictionaryContext = createContext<
  Record<string, string> | undefined | null
>(null);
Enter fullscreen mode Exit fullscreen mode

Setting up the translation provider

In the _app.tsx file (the entrypoint of the Next.js app) I'm importing my DictionaryContext, and using it's Provider to provide the translations to all the pages in my app by wrapping everything in the render function in the Provider.
The DictionaryContext.Provider takes a value prop, which should be the translations coming from POEditor. I fill the value with pageProps.dictionaryItems, which will be provided by the getStaticProps (or getServersideProps) function in the pages.

import '@/styles/globals.css';
import type { AppProps } from 'next/app';
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
import { DictionaryContext } from '../lib/dictionary-context';
import Link from 'next/link';
import { useRouter } from 'next/router';

export default function App({ Component, pageProps }: AppProps) {
  const router = useRouter();
  return (
    <DictionaryContext.Provider value={pageProps.dictionaryItems}>
      <header className="flex justify-center">
        <nav>
          <ul className="flex py-4 gap-x-4">
            <li>
              <Link
                className={`p-2 border ${
                  router.asPath === '/' ? 'border-orange-500' : ''
                }`}
                href="/"
              >
                Home
              </Link>
            </li>
            <li>
              <Link
                className={`p-2 border ${
                  router.asPath === '/detail' ? 'border-orange-500' : ''
                }`}
                href="/detail"
              >
                Detailpage
              </Link>
            </li>
          </ul>
        </nav>
        <div className="fixed top-4 right-4">
          <Link
            className={`${
              router.locale === 'en' ? 'text-orange-500 font-bold' : ''
            }`}
            href={router.asPath}
            locale="en"
          >
            EN
          </Link>
          <Link
            className={`ml-2 ${
              router.locale === 'nl' ? 'text-orange-500 font-bold' : ''
            }`}
            href={router.asPath}
            locale="nl"
          >
            NL
          </Link>
        </div>
      </header>
      <main className={inter.className}>
        <Component {...pageProps} />
      </main>
    </DictionaryContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Setting up the translation hook

For my custom translation hook I'm using the useContext hook to get the translations from the DictionaryContext and return the translation for the given key. I also added a second parameter to the function, which is an object containing variables which can be used in the translation. The translation should contain the variable name between double curly braces, and the variable will be replaced with the value passed to the translation function.

import { useContext } from "react";
import { DictionaryContext } from "../lib/dictionary-context";

const useTranslation = () => {
  const translations = useContext(DictionaryContext);
  const t = (
    key: string,
    variables?: {
      [key: string]: string | number;
    },
  ) => {
    if (!translations) {
      return key;
    }
    if (variables) {
      return Object.keys(variables).reduce((acc, variableKey) => {
        return acc.replace(
          new RegExp(`{{${variableKey}}}`, "g"),
          variables[variableKey].toString(),
        );
      }, translations[key] || key);
    }
    return translations[key] || key;
  };
  return t;
};

export default useTranslation;
Enter fullscreen mode Exit fullscreen mode

Setting up the translation fetching in the pages

In my demo I'm using getStaticProps to avoid too many fetches to my backend, but you could also use getServerSideProps if you want to fetch the translations on every request.
In the getStaticProps function I'm fetching the translations for the current locale, and returning them in the dictionaryItems prop.
I also chose to add the revalidate: 300 option to make sure the translations are only refetched every 5 minutes.

import Cta from "@/components/Cta";
import useTranslation from "@/hooks/use-translation";
import { getDictionaryItems } from "@/lib/api";
import { GetStaticProps } from "next";

export default function Home() {
  const t = useTranslation();
  return (
    <div className="flex flex-col items-center justify-center h-screen">
      <h1 className="text-3xl font-bold mb-4">{t("homepage.title")}</h1>
      <p className="italic">{t("homepage.description")}</p>
      <Cta />
    </div>
  );
}

export const getStaticProps: GetStaticProps = async ({ locale }) => {
  const dictionaryItems = await getDictionaryItems(locale ?? "en");
  return {
    props: {
      dictionaryItems,
    },
    revalidate: 300,
  };
};
Enter fullscreen mode Exit fullscreen mode

Inside my React components I can now simply use the useTranslation hook to get the translation function, and then use the function to translate the given key. In the Cta component I'm also using the second parameter of the translation function to pass a variable to the translation. The translation value in POEditor looks like this: Cta description with count_variable: {{count_variable}}.

import useTranslation from "@/hooks/use-translation";

const Cta = () => {
  const t = useTranslation();
  return (
    <div className="p-6 border text-center mt-4">
      <h2>{t("cta.title")}</h2>
      <p>{t("cta.description", { count_variable: 20 })}</p>
    </div>
  );
};
export default Cta;
Enter fullscreen mode Exit fullscreen mode

Conclusion

This is a very basic translation system, but it works well for my use case, and has been enough for the projects at work.
I hope it's helpful for you as well, and if you have any questions or feedback, feel free to leave a comment below.
Links to source code and live demo can be found at the top of this blog post.

Top comments (0)