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:
/
shows us our homepage in the default locale we chose./en-US/blog
or /tr-TR/blog
etc.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.
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.
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.
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,
})
We’r ready to create our layouts and pages. We will have a root layout+page and locale based layout+page:
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.
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:
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>
);
}
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.