Murat Gözel

Murat Gözel

Independent software developer & designer

Making Static Multilingual Web Apps with React, next.js and next-intl

August 10, 2024
Software Engineering

I’ll explain how you would do a multilingual web app with next.js static export and with next-intl in this article. The reason I choose static export case is just because it fits to many use cases and maybe there is some young devs waiting to be attracted to focus on when to use server side and client side code. I appreciate the creators of the next-intl package as it gives us a chance to place a translation layer into our apps nicely. On the other hand, I would like reader to keep in mind that explanations here are subject to change in a very short period of time. But the concept will stay same.

At the end, we’ll have the following like navigation in our web app:

  1. The / shows us our homepage in the default locale we chose.
  2. The navigation is going to work URL based: /en-US/blog or /tr-TR/blog etc.
  3. There is no persistence in user preferred locale. It can be implemented in client side too.

Configure First

Configure next.js to export static and with next-intl customization:

import createNextIntlPlugin from 'next-intl/plugin'

const withNextIntl = createNextIntlPlugin()

/** @type {import('next').NextConfig} */
const nextConfig = {
    // static export configuration:
    output: 'export',
    trailingSlash: true,
    skipTrailingSlashRedirect: true,
    distDir: 'dist' // WARNING: this is personal preference
};

export default withNextIntl(nextConfig);

It’s useful to have our constants in a config file:

import { LocalePrefix, type Pathnames } from 'next-intl/routing'

export const AVAILABLE_LOCALES = ['en-US', 'tr-TR']
export const DEFAULT_LOCALE = 'en-US'

export const localePrefix = 'always' satisfies LocalePrefix
export const locales = AVAILABLE_LOCALES
export const pathnames = {
    '/': '/',
} satisfies Pathnames

So our default locale will be en-US and we will also be available in tr-TR. We’r setting the localePrefix to always because we want our URLs depends on locale and we don’t want to check user’s locale during a request because this will be a static export.

Message Catalogs

Let’s create our message catalogs:

{
    "Home": {
        "metadata_title": "Home",
        "metadata_description": "Turkish based company established to provide some great services."
    },
    "Shared": {
        "copyright": "{legalName} {currentYear} © All rights reserved."
    },
}
{
    "Home": {
        "metadata_title": "Anasayfa",
        "metadata_description": "Harika hizmetler sağlayan bir işletme."
    },
    "Shared": {
        "copyright": "{legalName} {currentYear} © Tüm Hakları Saklıdır."
    }
}

Do you really create these catalogs manually? Why not you try to use a content management system and fetch this message catalogs from there? Thanks.

Load Translations Instruction

Let’s load our messages before creating our layout and pages:

import { notFound } from 'next/navigation'
import { getRequestConfig } from 'next-intl/server'
import { AVAILABLE_LOCALES } from '@/config'

export default getRequestConfig(async ({ locale }) => {
    if (!AVAILABLE_LOCALES.includes(locale)) {
        return notFound()
    }

    return {
        messages: (await import(`../messages/${locale}.json`)).default,
    }
})

With this code, we’ll be sure have correct translations in our components.

Navigation Helpers

There is one more thing ✨ before going to layouts and pages and that’s the navigation helpers. next-intl has some navigation helpers that we can use instead of next.js’s helpers. Their advantage is they are language aware. Things like <Link href="/blog" /> becomes <Link href="/tr-TR/blog" /> with these helpers, very useful:

import { createLocalizedPathnamesNavigation } from 'next-intl/navigation'
import { AVAILABLE_LOCALES, localePrefix, pathnames } from '@/config'

export const { Link, redirect, usePathname, useRouter, getPathname } =
    createLocalizedPathnamesNavigation({
        locales: AVAILABLE_LOCALES,
        pathnames,
        localePrefix,
    })

Root Layouts & Pages

We’r ready to create our layouts and pages. We will have a root layout+page and locale based layout+page:

  • app/layout.tsx
  • app/page.tsx
    • app/[locale]/layout.tsx
    • app/[locale]/page.tsx

The only thing we know is the default locale at root:

import { DEFAULT_LOCALE } from '@/config'

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
    return (
        <html lang={DEFAULT_LOCALE} className={`some css classes will come here, probably`}>
            <body>
                {children}
            </body>
        </html>
    );
}
import {redirect} from 'next/navigation'
import { DEFAULT_LOCALE } from '@/config'

// Redirect the user to the default locale when `/` is requested
export default function RootPage() {
    redirect('/' + DEFAULT_LOCALE)
}

As you see these are just initial renders and they have nothing to do with localization.

Locale Aware Layout

I won’t go into specific implementation details of next-intl but it’s important to show how active locale and translations can be used to fetch metadata and content. In our layout, we’ll use:

  1. The generateMetadata function to create locale aware metadata,
  2. The generateStaticParams function to create locale aware URL structure,
  3. The root layout that fetches messages from our catalog
import type { Metadata, Viewport } from "next";
import "../globals.css";
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, getTranslations, unstable_setRequestLocale } from 'next-intl/server'
import { AVAILABLE_LOCALES, BRAND_NAME } from '@/config'

export const viewport: Viewport = {
    themeColor: '#ffffff',
    width: 'device-width',
    initialScale: 1,
    maximumScale: 1,
    userScalable: false,
    colorScheme: 'light dark'
}

export async function generateMetadata({ params: { locale } }: { params: { locale: string } }): Promise<Metadata> {
    const t = await getTranslations({ locale, namespace: 'Home' })
    const title = t('metadata_title') + ' | ' + BRAND_NAME
    const description = t('metadata_description')

    return {
        title: title,
        description: description,
        // other params
    }
}

export function generateStaticParams() {
    return AVAILABLE_LOCALES.map((locale) => ({locale}))
}

export default async function RootLayout({ children, params: { locale } }: Readonly<{ children: React.ReactNode, params: {locale: string} }>) {
    unstable_setRequestLocale(locale)
    // Providing all messages to the client
    // side is the easiest way to get started
    const messages = await getMessages()

    return (
        <NextIntlClientProvider messages={messages}>
            {children}
        </NextIntlClientProvider>
    );
}

Locale Aware React Components

And page.tsx is just a regular React component with locale and translations:

import { useTranslations } from 'next-intl'
import { unstable_setRequestLocale } from 'next-intl/server'
import { BRAND_NAME } from '@/config'
import LanguageSelection from '@/components/LanguageSelection'
import RouteChangeListener from '@/app/RouteChangeListener'

export default function Home({ params: { locale } }: { params: { locale: string } }) {
    unstable_setRequestLocale(locale)

    const t = useTranslations('Home')

  return (
      <>
          <h1 className="">{BRAND_NAME}</h1>
          <p className="">{t('metadata_description')}</p>

          <LanguageSelection />
          
          <RouteChangeListener />
      </>
  );
}

Basically this is it. Now if you do next build, you’ll see that the whole app rendered and saved as html files under dist directory. Files in this directory will just work under any server software such as nginx or apache. You just have to move them to a server or configure your git repository to auto move them on changes.

If you still reading and wondering how a language selection work, then here is the tip: use the router navigation helper on click to a new language and you’r good to go:

import { useRouter, usePathname } from '@/navigation'

export default function LanguageSelection() {
    const router = useRouter()
    const pathname = usePathname()
    
    function onChange(v) {
        router.replace({ pathname }, {locale: v.code})
    }
    
    // component render code
}

Thanks for reading.

All coffee beans reserved. © 2024 Murat Gözel
Built with next.js and react.