Type-safe internationalization (i18n) for Next.js
- 100% Type-safe: Locales in TS or JSON, type-safe
t()
&scopedT()
, type-safe params, type-safe plurals, type-safechangeLocale()
... - Small: No dependencies, lazy-loaded
- Simple: No Webpack configuration, no CLI, no code generation, just pure TypeScript
- SSR/SSG/CSR: Load only the required locale, client-side and server-side
- Pages or App Router: With support for React Server Components
Note: You can now build on top of the types used by next-international using international-types!
pnpm install next-international
Make sure that strict
is set to true
in your tsconfig.json
, then follow the guide for the Pages Router or the App Router.
You can also find complete examples inside the examples/next-pages and examples/next-app directories.
- Make sure that you've set up correctly the
i18n
key insidenext.config.js
, then createlocales/index.ts
with your locales:
// locales/index.ts
import { createI18n } from 'next-international'
export const { useI18n, useScopedI18n, I18nProvider, getLocaleProps } = createI18n({
en: () => import('./en'),
fr: () => import('./fr')
})
Each locale file should export a default object (don't forget as const
):
// locales/en.ts
export default {
'hello': 'Hello',
'hello.world': 'Hello world!',
'welcome': 'Hello {name}!'
} as const
- Wrap your whole app with
I18nProvider
inside_app.tsx
:
// pages/_app.tsx
import { I18nProvider } from '../locales'
import { AppProps } from 'next/app'
export default function App({ Component, pageProps }: AppProps) {
return (
<I18nProvider locale={pageProps.locale}>
<Component {...pageProps} />
</I18nProvider>
)
}
- Add
getLocaleProps
to your pages, or wrap your existinggetStaticProps
(this will allow to SSR locales, see Load initial locales client-side if you want to load the initial locale client-side):
// pages/index.tsx
export const getStaticProps = getLocaleProps()
// or with an existing `getStaticProps` function:
export const getStaticProps = getLocaleProps(ctx => {
// your existing code
return {
...
}
})
If you already have getServerSideProps
on this page, you can't use getStaticProps
. In this case, you can still use getLocaleProps
the same way:
// pages/index.tsx
export const getServerSideProps = getLocaleProps()
// or with an existing `getServerSideProps` function:
export const getServerSideProps = getLocaleProps(ctx => {
// your existing code
return {
...
}
})
- Use
useI18n
anduseScopedI18n()
:
// pages/index.ts
import { useI18n, useScopedI18n } from '../locales'
// export const getStaticProps = ...
// export const getServerSideProps = ...
export default function Page() {
const t = useI18n()
const scopedT = useScopedI18n('hello')
return (
<div>
<p>{t('hello')}</p>
{/* Both are equivalent: */}
<p>{t('hello.world')}</p>
<p>{scopedT('world')}</p>
<p>{t('welcome', { name: 'John' })}</p>
<p>{t('welcome', { name: <strong>John</strong> })}</p>
</div>
)
}
- Create
locales/client.ts
andlocales/server.ts
with your locales:
// locales/client.ts
import { createI18nClient } from 'next-international/client'
export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient({
en: () => import('./en'),
fr: () => import('./fr')
})
// locales/server.ts
import { createI18nServer } from 'next-international/server'
export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer({
en: () => import('./en'),
fr: () => import('./fr')
})
Each locale file should export a default object (don't forget as const
):
// locales/en.ts
export default {
'hello': 'Hello',
'hello.world': 'Hello world!',
'welcome': 'Hello {name}!'
} as const
- Move all your routes inside an
app/[locale]/
folder. For Client Components, wrap the lowest parts of your app withI18nProviderClient
inside a layout:
// app/[locale]/client/layout.tsx
import { ReactElement } from 'react'
import { I18nProviderClient } from '../../locales/client'
export default function SubLayout({
children,
params
}: {
children: ReactElement
params: { locale: string }
}) {
return (
<I18nProviderClient locale={params.locale}>
{children}
</I18nProviderClient>
)
}
- (WIP) If you want to support SSG with
output: export
, addgetStaticParams
to your pages:
// app/[locale]/page.tsx
import { ..., getStaticParams } from '../../locales/server'
export const generateStaticParams = getStaticParams()
- Add a
middleware.ts
file at the root of your app, that will redirect the user to the right locale. You can also rewrite the URL to hide the locale:
// middleware.ts
import { createI18nMiddleware } from 'next-international/middleware'
import { NextRequest } from 'next/server'
const I18nMiddleware = createI18nMiddleware(['en', 'fr'] as const, 'fr')
export function middleware(request: NextRequest) {
return I18nMiddleware(request)
}
export const config = {
matcher: ['/((?!_next).*)']
}
- Use
useI18n
anduseScopedI18n()
/getI18n
andgetScopedI18n()
inside your components:
// Client Component
'use client'
import { useI18n, useScopedI18n } from '../../locales/client'
export default function Page() {
const t = useI18n()
const scopedT = useScopedI18n('hello')
return (
<div>
<p>{t('hello')}</p>
{/* Both are equivalent: */}
<p>{t('hello.world')}</p>
<p>{scopedT('world')}</p>
<p>{t('welcome', { name: 'John' })}</p>
<p>{t('welcome', { name: <strong>John</strong> })}</p>
</div>
)
}
// Server Component
import { getI18n, getScopedI18n } from '../../locales/server'
export default async function Page() {
const t = await getI18n()
const scopedT = await getScopedI18n('hello')
return (
<div>
<p>{t('hello')}</p>
{/* Both are equivalent: */}
<p>{t('hello.world')}</p>
<p>{scopedT('world')}</p>
<p>{t('welcome', { name: 'John' })}</p>
<p>{t('welcome', { name: <strong>John</strong> })}</p>
</div>
)
}
When you have a lot of keys, you may notice in a file that you always use and duplicate the same scope:
// We always repeat `pages.settings`
t('pages.settings.title')
t('pages.settings.description', { identifier })
t('pages.settings.cta')
We can avoid this using the useScopedI18n
hook / getScopedI18n
method. And of course, the scoped key, subsequent keys and params will still be 100% type-safe.
Pages Router
Export useScopedI18n
from createI18n
:
// locales/index.ts
export const {
useScopedI18n,
...
} = createI18n({
...
})
Then use it in your component:
import { useScopedI18n } from '../locales'
export default function Page() {
const t = useScopedI18n('pages.settings')
return (
<div>
<p>{t('title')}</p>
<p>{t('description', { identifier })}</p>
<p>{t('cta')}</p>
</div>
)
}
App Router
Export useScopedI18n
from createI18nClient
and getScopedI18n
from createI18nServer
:
// locales/client.ts
export const {
useScopedI18n,
...
} = createI18nClient({
...
})
// locales/server.ts
export const {
getScopedI18n,
...
} = createI18nServer({
...
})
Then use it in your components:
// Client Component
'use client'
import { useScopedI18n } from '../../locales/client'
export default function Page() {
const t = useScopedI18n('pages.settings')
return (
<div>
<p>{t('title')}</p>
<p>{t('description', { identifier })}</p>
<p>{t('cta')}</p>
</div>
)
}
// Server Component
import { getScopedI18n } from '../../locales/server'
export default async function Page() {
const t = await getScopedI18n('pages.settings')
return (
<div>
<p>{t('title')}</p>
<p>{t('description', { identifier })}</p>
<p>{t('cta')}</p>
</div>
)
}
Plural translations work out of the box without any external dependencies, using the Intl.PluralRules
API, which is supported in all browsers and Node.js.
To declare plural translations, append #
followed by zero
, one
, two
, few
, many
or other
:
// locales/en.ts
export default {
'cows#one': 'A cow',
'cows#other': '{count} cows'
} as const
The correct translation will then be determined automatically using a mandatory count
parameter. The value of count
is determined by the union of all suffixes, enabling type safety:
zero
allows 0one
autocompletes 1, 21, 31, 41... but allows any numbertwo
autocompletes 2, 22, 32, 42... but allows any numberfew
,many
andother
allow any number
This works with the Pages Router, App Router in both Client and Server Components, and with scoped translations:
export default function Page() {
const t = useI18n()
return (
<div>
{/* Output: A cow */}
<p>{t('cows', { count: 1 })}</p>
{/* Output: 3 cows */}
<p>{t('cows', { count: 3 })}</p>
</div>
)
}
You can write locales using nested objects instead of the default dot notation. You can use the syntax you prefer without updating anything else:
// locales/en.ts
export default {
hello: {
world: 'Hello world!',
nested: {
translations: 'Translations'
}
}
} as const
It's the equivalent of the following:
// locales/en.ts
export default {
'hello.world': 'Hello world!',
'hello.nested.translations': 'Translations'
} as const
Pages Router
Export useChangeLocale
and useCurrentLocale
from createI18n
:
// locales/index.ts
export const {
useChangeLocale,
useCurrentLocale,
...
} = createI18n({
...
})
Then use it as a hook:
import { useChangeLocale, useCurrentLocale } from '../locales'
export default function Page() {
const changeLocale = useChangeLocale()
const locale = useCurrentLocale()
return (
<>
<p>Current locale: <span>{locale}</span></p>
<button onClick={() => changeLocale('en')}>English</button>
<button onClick={() => changeLocale('fr')}>French</button>
<>
)
}
App Router
You can only change the current locale from a Client Component. Export useChangeLocale
and useCurrentLocale
from createI18nClient
/ getCurrentLocale
from createI18nServer
:
// locales/client.ts
export const {
useChangeLocale,
useCurrentLocale,
...
} = createI18nClient({
...
})
// locales/server.ts
export const {
getCurrentLocale,
...
} = createI18nServer({
...
})
Then use these hooks:
// Client Component
'use client'
import { useChangeLocale, useCurrentLocale } from '../../locales/client'
export default function Page() {
const changeLocale = useChangeLocale()
const locale = useCurrentLocale()
return (
<>
<p>Current locale: <span>{locale}</span></p>
<button onClick={() => changeLocale('en')}>English</button>
<button onClick={() => changeLocale('fr')}>French</button>
<>
)
}
// Server Component
import { getCurrentLocale } from '../../locales/server'
export default function Page() {
const locale = getCurrentLocale()
return (
<p>Current locale: <span>{locale}</span></p>
)
}
If you have set a basePath
option inside next.config.js
, you'll also need to set it here:
const changeLocale = useChangeLocale({
basePath: '/your-base-path'
})
It's common to have missing translations in an application. By default, next-international outputs the key when no translation is found for the current locale, to avoid sending users unnecessary data.
You can provide a fallback locale that will be used for all missing translations:
// pages/_app.tsx
import { I18nProvider } from '../locales'
import en from '../locales/en'
<I18nProvider locale={pageProps.locale} fallbackLocale={en}>
...
</I18nProvider>
Warning: This should not be used unless you know what you're doing and what that implies.
If for x reason you don't want to SSR the initial locale, you can load it on the client. Simply remove the getLocaleProps
from your pages.
You can also provide a fallback component while waiting for the initial locale to load inside I18nProvider
:
<I18nProvider locale={pageProps.locale} fallback={<p>Loading locales...</p>}>
...
</I18nProvider>
You might have noticed that by default, next-international redirects and shows the locale in the URL (e.g /en/products
). This is helpful for users, but you can transparently rewrite the URL to hide the locale (e.g /products
).
Navigate to the middleware.ts
file and set the urlMappingStrategy
to rewrite
(the default is redirect
):
// middleware.ts
const I18nMiddleware = createI18nMiddleware(['en', 'fr'] as const, 'fr', {
urlMappingStrategy: 'rewrite'
})
We also provide a separate package called international-types that contains the utility types for next-international. You can build a library on top of it and get the same awesome type-safety.
In case you want to make tests with next-international, you will need to create a custom render. The following example uses @testing-library
and Vitest
, but should work with Jest
too.
Testing example
// customRender.tsx
import { ReactElement } from 'react'
import { cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})
const customRender = (ui: ReactElement, options = {}) =>
render(ui, {
// wrap provider(s) here if needed
wrapper: ({ children }) => children,
...options,
})
export * from '@testing-library/react'
export { default as userEvent } from '@testing-library/user-event'
export { customRender as render }
You will also need your locales files, or one for testing purposes.
// en.ts
export default {
hello: 'Hello',
} as const
Then, you can later use it in your tests:
// *.test.tsx
import { describe, vi } from 'vitest'
import { createI18n } from 'next-international'
import { render, screen, waitFor } from './customRender' // Our custom render function.
import en from './en' // Your locales.
// Don't forget to mock the "next/router", not doing this may lead to some console errors.
beforeEach(() => {
vi.mock('next/router', () => ({
useRouter: vi.fn().mockImplementation(() => ({
locale: 'en',
defaultLocale: 'en',
locales: ['en', 'fr'],
})),
}))
})
afterEach(() => {
vi.clearAllMocks()
})
describe('Example test', () => {
it('just an example', async () => {
const { useI18n, I18nProvider } = createI18n({
en: () => import('./en'),
})
function App() {
const t = useI18n()
return <p>{t('hello')}</p>
}
render(
<I18nProvider locale={en}>
<App />
</I18nProvider>
)
expect(screen.queryByText('Hello')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('Hello')).toBeInTheDocument()
})
})
})