Type-safe internationalization (i18n) for Next.js
Features
-
100% Type-safe: Locales in TS or JSON, type-safe
t()
&scopedT()
, type-safe params - Small: 1.2 KB gzipped (1.7 KB uncompressed), no dependencies
- Simple: No webpack configuration, no CLI, just pure TypeScript
- SSR: Load only the required locale, SSRed
Note: You can now build on top of the types used by next-international using international-types!
Usage
pnpm install next-international
-
Make sure that you've followed Next.js Internationalized Routing, and that
strict
is set totrue
in yourtsconfig.json
-
Create
locales/index.ts
with your locales:
import { createI18n } from 'next-international'
import type Locale from './en'
export const { useI18n, I18nProvider, getLocaleProps } = createI18n<typeof Locale>({
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',
welcome: 'Hello {name}!',
} as const
- Wrap your whole app with
I18nProvider
inside_app.tsx
:
// pages/app.tsx
import { I18nProvider } from '../locales'
function App({ Component, pageProps }) {
return (
<I18nProvider locale={pageProps.locale}>
<Component {...pageProps} />
</I18nProvider>
)
}
- Add
getLocaleProps
to your pages, or wrap your existinggetStaticProps
(this will allows SSR locales, see Load initial locales client-side if you want to load the initial locale client-side):
// locales/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:
export const getServerSideProps = getLocaleProps()
// or with an existing `getServerSideProps` function:
export const getServerSideProps = getLocaleProps((ctx) => {
// your existing code
return {
...
}
})
- Use
useI18n
:
import { useI18n } from '../locales'
function App() {
const { t } = useI18n()
return (
<div>
<p>{t('hello')}</p>
<p>{t('welcome', { name: 'John' })}</p>
<p>{t('welcome', { name: <strong>John</strong> })}</p>
</div>
)
}
Examples
Scoped translations
When you have a lot of keys, you may notice in a file that you always use and such 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 scoped translations with the scopedT
function from useI18n
:
const { scopedT } = useI18n()
const t = scopedT('pages.settings')
t('title')
t('description', { identifier })
t('ct')
And of course, the scoped key, subsequents keys and params will still be 100% type-safe.
Change current locale
Export useChangeLocale
from createI18n
:
// locales/index.ts
export const {
useChangeLocale,
...
} = createI18n({
...
})
Then use this as a hook:
import { useChangeLocale } from '../locales'
function App() {
const changeLocale = useChangeLocale()
return (
<button onClick={() => changeLocale('en')}>English</button>
<button onClick={() => changeLocale('fr')}>French</button>
)
}
Fallback locale for missing translations
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 to users uncessary 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>
Use JSON files instead of TS for locales
Currently, this breaks the parameters type-safety, so we recommend using the TS syntax. See this issue: https://github.com/microsoft/TypeScript/issues/32063.
You can still get type-safety by explicitly typing the locales
// locales/index.ts
import { createI18n } from 'next-international'
import type Locale from './en.json'
export const { useI18n, I18nProvider, getLocaleProps } = createI18n<typeof Locale>({
en: () => import('./en.json'),
fr: () => import('./fr.json'),
})
Explicitly typing the locales
If you want to explicitly type the locale, you can create an interface that extends BaseLocale
and use it as the generic in createI18n
:
// locales/index.ts
import { createI18n, BaseLocale } from 'next-international'
interface Locale extends BaseLocale {
'hello': string
'welcome': string
}
export const {
...
} = createI18n<Locale>({
en: () => import('./en.json'),
fr: () => import('./fr.json'),
})
Load initial locales client-side
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>
Type-safety on locales files
Using defineLocale
, you can make sure all your locale files implements all the keys of the base locale:
// locales/index.ts
export const {
defineLocale
...
} = createI18n({
...
})
It's a simple wrapper function around other locales:
// locales/fr.ts
export default defineLocale({
hello: 'Bonjour',
welcome: 'Bonjour {name}!',
})
Use the types for my own library
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.
Testing
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.
// customRender.tsx
import { cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})
const customRender = (ui: React.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'
// override render export
export { customRender as render }
You will also need a locale created, or one for testing purposes.
// en.ts
export default {
hello: 'Hello',
} as const
Then, you can later use it in your tests like this.
// *.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<typeof import('./en')>({
en: () => import('./en'),
// Other locales you might have.
})
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()
})
})
})