DEV Community

Cover image for How to implement internationalization (i18n) in a Next.js project without changing the URL
Junior
Junior

Posted on

How to implement internationalization (i18n) in a Next.js project without changing the URL

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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',
}
Enter fullscreen mode Exit fullscreen mode

src/i18n/locales/en-US.ts

export default {
  English: 'Inglês',
  Portuguese: 'Português',
 'Page not found': 'Página não encontrada',
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

For example:
Image of how the folders will look

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 
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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.