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'
export const { useI18n, 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',
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):
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 the useScopedI18n
hook. Export it from createI18n
:
// locales/index.ts
export const {
useScopedI18n,
...
} = createI18n({
...
})
Then use it in your component:
import { useScopedI18n } from '../locales';
function App() {
const t = useScopedI18n('pages.settings');
return (
<div>
<p>{t('title')}</p>
<p>{t('description', { identifier })}</p>
<p>{t('cta')}</p>
</div>
);
}
And of course, the scoped key, subsequents keys and params will still be 100% type-safe.
Change and get current locale
Export useChangeLocale
and useCurrentLocale
from createI18n
:
// locales/index.ts
export const {
useChangeLocale,
useCurrentLocale,
...
} = createI18n({
...
})
Then use this as a hook:
import { useChangeLocale, useCurrentLocale } from '../locales'
function App() {
const changeLocale = useChangeLocale()
const locale = useCurrentLocale()
// ^ typed as 'en' | 'fr'
return (
<>
<p>Current locale: <span>{locale}</span></p>
<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: microsoft/TypeScript#32063.
You can still get type-safety by explicitly typing the locales
// locales/index.ts
import { createI18n } from 'next-international'
export const { useI18n, I18nProvider, getLocaleProps } = createI18n({
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 } from 'next-international';
type Locale = {
hello: string;
welcome: string;
}
type Locales = {
en: Locale;
fr: Locale;
}
export const {
...
} = createI18n<any, Locales>({
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();
});
});
});