In this article, I will objectively and directly show how to implement internationalization (i18n) in a Next.js project using next-intl
, without tying languages to the URL paths — in other words, no paths like example.com/en
. This approach avoids the need for extra handling in case the user manually changes the URL.
We'll use a cookie to identify and store the user's selected language. This cookie will be automatically set on the user's first visit to the site. If the user wishes to change the language later, they’ll be able to do so through the developed platform.
Initial setup
If you don’t have a Next.js project set up yet, you can follow this tutorial: How to set up a new Next.js project.
Now, install the next-intl
package, which will help us configure i18n.
npm install next-intl
Let’s structure the project folders:
├── src
│ ├── app
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── components
│ ├── hooks
│ └── i18n
│ ├── locales
│ │ ├── en-US.ts
│ │ └── pt-BR.ts
│ ├── config.ts
│ ├── locale.ts
│ └── request.ts
│ ├── services
│ └── styles
Today we’ll focus only on the i18n
folder. In a future article, I’ll talk more about folder organization.
1 - Configuring the layout.tsx file (entry point)
src/app/layout.tsx
import { Metadata } from 'next'
import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server'
import { ReactNode } from 'react'
export const metadata: Metadata = {
title: 'NextJS',
description: 'Site do NextJS'
}
async function RootLayout({ children }: { children: ReactNode }) {
const locale = await getLocale()
const messages = await getMessages()
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
Here, we configure the NextIntlClientProvider
, which receives messages from getMessages()
and the current language from getLocale()
, both imported from next-intl/server
.
2 - Setting up the i18n
folder
This structure stores all configurations related to i18n usage in the project.
2.1 - config.ts
file
src/i18n/config.ts
export type LocaleProps = (typeof locales)[number]
export const locales = ['en-US', 'pt-BR'] as const
export const defaultLocale: LocaleProps = 'en-US'
Here we export the language typings that will be used in the project.
2.2 - locale.ts
file
src/i18n/locale.ts
'use server'
import { cookies } from 'next/headers'
import { defaultLocale, LocaleProps } from './config'
const COOKIE_NAME = `${process.env.NEXT_PUBLIC_PROJECT_NAME}-i18n`
export async function getUserLocale() {
return (await cookies()).get(COOKIE_NAME)?.value || defaultLocale
}
export async function setUserLocale(locale: LocaleProps) {
;(await cookies()).set(COOKIE_NAME, locale)
}
This file runs on the server. Here, we manage reading and updating the cookie. It’s up to you whether or not to include the project name as part of the cookie key.
2.3 - request.ts
file
src/i18n/request.ts
import {
useLocale as useNextIntlLocale,
useTranslations as useNextIntlTranslations
} from 'next-intl'
import { getRequestConfig, setRequestLocale } from 'next-intl/server'
import { getUserLocale } from './locale'
export default getRequestConfig(async () => {
const locale = await getUserLocale()
return {
locale,
messages: (await import(`./locales/${locale}.ts`)).default
}
})
export function setLocale(locale: 'pt-BR' | 'en-US') {
setRequestLocale(locale)
}
export function useLocale() {
const locale = useNextIntlLocale()
return locale
}
export function useTranslations() {
const t = useNextIntlTranslations()
return t as (key: string) => string
}
The request
file handles retrieving and assembling the locale JSONs (from the locales
folder, which I’ll explain below), updating the language, retrieving the active one, and also a translation wrapper. This format helps in case we ever want to change the next-intl
library — we’d only need to update these functions and the whole project would still work.
2.4 - locales
folder
This folder contains the files that store all translations of our project.
Example:
export default {
English: 'English',
Portuguese: 'Portuguese',
'Page not found': 'Page not found',
}
src/i18n/locales/en-US.ts
export default {
English: 'Inglês',
Portuguese: 'Português',
'Page not found': 'Página não encontrada',
}
src/i18n/locales/pt-BR.ts
One detail: I use the actual text (in English) as the translation keys. This simplifies fallbacks when there’s an error in the translation library — at least the system shows the English text. Also, it avoids duplication and makes the JSON easier to read and maintain, especially in large systems.
Standard usage example:
import { useTranslations } from 'next-intl'
export default function HomePage() {
const t = useTranslations()
return (
<div>
<h1>{t('title')}</h1>
<h1>{t('subtitle')}</h1>
</div>
)
}
Example usage 2:
import { useTranslations } from 'next-intl'
export default function HomePage() {
const t = useTranslations()
return (
<div>
<h1>{t('Home page')}</h1>
<h1>{t('Home page subtitle')}</h1>
</div>
)
}
I personally prefer Example 1.
Attention: next-intl
may throw an error if a key ends with a period, like 'Home page.'
. This doesn’t happen with the react-i18next
library (topic for a future article 😄).
Another thing I like about this folder organization is separating the project’s translation JSONs.
In this example, we have two i18n folders — one in each module (a module pattern centralizes everything within its module, such as Auth, Home, User, Profile, Company — making maintenance and merges easier. I’ll go into detail in a future article 😄).
Now let’s see how our locales
file would look:
import Auth from '@/app/(auth)/i18n/en-US'
import Dashboard from '@/app/(dashoard)/i18n/en-US'
export default {
English: 'English',
'Page not found': 'Page not found',
...Auth,
...Dashboard
}
By using spread syntax, we keep files small and organized. And by using the text itself as keys, we avoid conflicts between modules.
Let’s use what we’ve built
To use this in our project, the simplest approach would be:
import { useTranslations } from 'next-intl'
export default function HomePage() {
const t = useTranslations()
return (
<div>
<h1>{t('Home page')}</h1>
<h1>{t('Home page subtitle')}</h1>
</div>
)
}
But since you've already seen this format, let’s improve it:
import { useTranslations } from 'next-intl'
interface TypographyProps {
text: string
className?: string
length?: number
variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span' | 'strong'
}
function Typography({ variant, text, className, length }: TypographyProps) {
const Component = variant || 'span'
const t = useTranslations()
const textTranslation = t(text)
return (
<Component className={className}>
{length && textTranslation.length > length ? (
<>{textTranslation.substring(0, length)}...</>
) : (
textTranslation
)}
</Component>
)
}
export { Typography }
Here we created a Typography
component that can be any HTML tag, received via the variant
prop. It also accepts a text
prop that will be translated inside the component. You can also set a length
limit on the displayed text. This way, you don’t need to import useTranslations()
in every file — you can extend this same pattern for inputs, labels, etc.
Conclusion
There are many ways to organize and apply i18n in your project. In this article, I shared a model that I personally like and find very productive. It might not be the "best", but it’s functional, scalable, and easy to maintain. Feel free to adapt and evolve it based on your project's needs.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.