remix-intl
TypeScript icon, indicating that this package has built-in type declarations

0.0.12 • Public • Published

remix-intl

The best internationalization(i18n) library for your Remix apps.

Features:

  • 🥳 Powerful and fully under your control
  • 🚀 Minimal size, less dependencies

TODO

  • [ ] Make setup more simple
  • [ ] Unit tests and E2E tests

What does it look like?

// app/._index.tsx
import { ActionFunctionArgs, json, type MetaFunction } from '@remix-run/node';
import { Form, useActionData, useLoaderData } from '@remix-run/react';
import { useT } from 'remix-intl';
import { getT } from 'remix-intl/server';

export const meta: MetaFunction = ({ location }) => {
  const { t } = getT(location);
  return [{ title: t('title') }];
};

export default function Index() {
  const { locales } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();
  const { t } = useT();

  return (
    <div>
      <h1>{t('create_todo')}</h1>
      <Form method="post">
        <input type="text" name="title" />

        {actionData?.errors?.title ? <em>{actionData?.errors.title}</em> : null}

        <button type="submit">{t('create_todo')}</button>
      </Form>
    </div>
  );
}

export async function action({ request }: ActionFunctionArgs) {
  const body = await request.formData();
  const { t } = getT(request.url);
  if (!body.get('title')) {
    return json({ errors: { title: t('required') } });
  }
}

public/locales/en/index.json

{
  "title": "Remix App",
  "hi": "Hello",
  "required": "Required",
  "create_todo": "Create Todo"
}

Table of Contents

Installing

# npm
npm install remix-intl i18next

# pnpm
pnpm add remix-intl i18next

# yarn
yarn add remix-intl i18next

Configuration

1. Create files

Create i18n config file

app/i18n.ts

// app/i18n.ts
import { createInstance } from 'i18next';
import type { GetLocalesRes, GetMessagesRes, IntlConfig } from 'remix-intl/types';
import { setIntlConfig } from 'remix-intl/i18n';

const defaultNS = 'remix_intl';
const i18next = createInstance({ defaultNS, ns: [defaultNS], resources: {} });
i18next.init({
  defaultNS,
  ns: [defaultNS],
  resources: {},
});

async function getLocales(): Promise<GetLocalesRes> {
  // you can fetch dynamic locales from others API
  return { locales: ['zh-CN', 'en'] };
}

async function getMessages(locale: string, ns?: string): Promise<GetMessagesRes> {
  // you can fetch dynamic messages from others API
  const messages = await fetch(
    `http://localhost:5173/locales/${locale}/${ns || 'index'}.json`
  ).then((res) => res.json());
  return { messages, locale, ns };
}

export const intlConfig: IntlConfig = {
  mode: 'search',
  paramKey: 'lang',
  cookieKey: 'remix_intl',
  defaultNS,
  clientKey: 'remix_intl',
  defaultLocale: '',
  getLocales,
  getMessages,
  i18next,
};

setIntlConfig(intlConfig);

export default i18next;

Create i18n cookie file

app/i18n.server.ts

// app/i18n.server.ts
import { createCookie } from '@remix-run/node';
import { intlConfig } from './i18n';

export const i18nCookie = createCookie(intlConfig.cookieKey);

Create i18n navigation components file

app/navigation.tsx

// app/navigation.tsx
import { createSharedPathnamesNavigation } from 'remix-intl/navigation';

const { Link, NavLink, useNavigate, SwitchLocaleLink } = createSharedPathnamesNavigation();

export { Link, NavLink, useNavigate, SwitchLocaleLink };

2. Update

Update server entry

app/entry.server.tsx: 3 changes

// app/entry.server.tsx
import { PassThrough } from 'node:stream';

import type { AppLoadContext, EntryContext } from '@remix-run/node';
import { createReadableStreamFromReadable } from '@remix-run/node';
import { RemixServer } from '@remix-run/react';
import { isbot } from 'isbot';
import { renderToPipeableStream } from 'react-dom/server';

/* --- 1.IMPORT THIS --- */
import { initIntl } from 'remix-intl/server';
import { i18nCookie } from './i18n.server';
/* --- 1.IMPORT THIS END --- */

const ABORT_DELAY = 5_000;

/* --- 2.ADD `async` --- */
export default async function handleRequest(
  /* --- 2.ADD `async` end --- */
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
  loadContext: AppLoadContext
) {
  /* --- 3.ADD THIS --- */
  await initIntl(request, i18nCookie);
  /* --- 3.ADD THIS END --- */

  return isbot(request.headers.get('user-agent') || '')
    ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
    : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);
}

function handleBotRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  return new Promise((resolve, reject) => {
    let shellRendered = false;
    const { pipe, abort } = renderToPipeableStream(
      <RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
      {
        onAllReady() {
          shellRendered = true;
          const body = new PassThrough();
          const stream = createReadableStreamFromReadable(body);

          responseHeaders.set('Content-Type', 'text/html');

          resolve(
            new Response(stream, {
              headers: responseHeaders,
              status: responseStatusCode,
            })
          );

          pipe(body);
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          responseStatusCode = 500;
          if (shellRendered) {
            console.error(error);
          }
        },
      }
    );

    setTimeout(abort, ABORT_DELAY);
  });
}

function handleBrowserRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  return new Promise((resolve, reject) => {
    let shellRendered = false;
    const { pipe, abort } = renderToPipeableStream(
      <RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
      {
        onShellReady() {
          shellRendered = true;
          const body = new PassThrough();
          const stream = createReadableStreamFromReadable(body);

          responseHeaders.set('Content-Type', 'text/html');

          resolve(
            new Response(stream, {
              headers: responseHeaders,
              status: responseStatusCode,
            })
          );

          pipe(body);
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          responseStatusCode = 500;
          if (shellRendered) {
            console.error(error);
          }
        },
      }
    );

    setTimeout(abort, ABORT_DELAY);
  });
}

Update client entry

app/entry.client.tsx: 2 changes

// app/entry.client.tsx
import { RemixBrowser } from '@remix-run/react';
import { startTransition, StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';

/* --- 1.IMPORT THIS --- */
import { ClientProvider as IntlProvider } from 'remix-intl';
/* --- 1.IMPORT THIS END --- */

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      {/* --- 2.ADD THIS --- */}
      <IntlProvider>
        <RemixBrowser />
      </IntlProvider>
      {/* --- 2.ADD THIS END--- */}
    </StrictMode>
  );
});

Update root.tsx

app/root.tsx: 4 changes

// app/root.tsx

/* --- 1.IMPORT THIS --- */
import './i18n';
import { parseLocale } from 'remix-intl/server';
import { IntlScript } from 'remix-intl';
import { i18nCookie } from './i18n.server';
/* --- 1.IMPORT THIS END --- */

import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  json,
  redirect,
} from '@remix-run/react';
import { LoaderFunctionArgs } from '@remix-run/node';

export async function loader({ request }: LoaderFunctionArgs) {
  /* --- 2.ADD THIS --- */
  const res = await parseLocale(request, i18nCookie);
  if (res.isRedirect) {
    return redirect(res.redirectURL);
  }
  return json(res, {
    headers: {
      'Set-Cookie': await i18nCookie.serialize(res.locale),
    },
  });
  /* --- 2.ADD THIS END --- */
}

export function Layout({ children }: { children: React.ReactNode }) {
  /* --- 3.ADD THIS --- */
  const { locale, dir } = useLoaderData<typeof loader>();
  return (
    <html lang={locale} dir={dir}>
      {/* --- 3.ADD THIS END --- */}
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
        {/* --- 4.ADD THIS --- */}
        <IntlScript />
        {/* --- 4.ADD THIS END --- */}
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}

Create i18n messages

public/locales/en/index.json

{
  "hi": "Hello"
}

public/locales/zh-CN/index.json

{
  "hi": "您好"
}

Usage

Different mode: segment or search

segment mode: https://example.com/locale/path

search mode: https://example.com/path?lang=locale

Default is search mode, you can update mode in app/i18n.ts config file.

If you choose segment mode, don't forget add file prefix ($lang). to your routes files

paramKey

Default is lang, you can change to others you like.

Switch different languages

No need refresh page example:

import { SwitchLocaleLink } from '~/navigation';

const langs = [
  {
    text: 'English',
    code: 'en',
  },
  {
    text: '简体中文',
    code: 'zh-CN',
  },
];

export default function LanguageSwitcher() {
  return (
    <div>
      {langs.map((item, idx) => {
        return (
          <SwitchLocaleLink key={item.locale} locale={item.code} query={{ idx }}>
            {item.text}
          </SwitchLocaleLink>
        );
      })}
    </div>
  );
}

Refresh page example:

import { SwitchLocaleLink } from '~/navigation';

const langs = [
  {
    text: 'English',
    code: 'en',
  },
  {
    text: '简体中文',
    code: 'zh-CN',
  },
];

export default function LanguageSwitcher() {
  return (
    <div>
      {langs.map((item) => {
        return (
          <SwitchLocaleLink reloadDocument key={item.locale} locale={item.code}>
            {item.text}
          </SwitchLocaleLink>
        );
      })}
    </div>
  );
}

Link, NavLink and useNavigate

import { Link, NavLink, useNavigate } from '~/navigation';

export default function LinkNavigate() {
  const navigate = useNavigate();
  return (
    <div>
      {/* /docs?lang=[locale] */}
      <Link to="/docs">Documents</Link>
      {/* /docs?lang=[locale] */}
      <NavLink to="/docs">Documents</NavLink>
      <button
        onClick={() => {
          /* /docs?lang=[locale] */
          navigate('/docs');
        }}>
        Go to Documents
      </button>
    </div>
  );
}

useT and useLocale

In React components, we can use useLocale to get current locale code,

and useT can get t function to translate:

import { useLocale, useT } from 'remix-intl';

export default function RemixIntlExample() {
  const locale = useLocale();
  const { t, locale: sameWithLocale } = useT();
  // or const { t,  locale: sameWithLocale } = useT(namespace);

  return (
    <div>
      <h1>{t('i18n_key')}</h1>
      <p>current locale: {locale}</p>
    </div>
  );
}

getT and getLocale in meta / loader / action

Out of react components, like inside meta, loader or action, we can use getT to get t function and translate:

import { getLocale, getT } from 'remix-intl/server';

// in `meta`
export const meta: MetaFunction = ({ location }) => {
  const { t, locale } = getT(location); // `getT` can receive location object or string pathname?search
  // or const { t, locale } = getT(location, namespace);
  const sameWithLocale = getLocale(location); // `getLocale` same paramater with `getT`

  return [{ title: t('i18n_key') }];
};

// in `loader`

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { t } = getT(request.url);
  const locale = getLocale(request.url);
  return json({ title: t('i18n_key'), locale });
};

// in `action`
export async function action({ request }: ActionFunctionArgs) {
  const body = await request.formData();
  const { t } = getT(request.url);
  if (!body.get('title')) {
    return json({ errors: { title: t('required') } });
  }
  return redirect(request.url);
}

API

remix-intl API

// hooks
import { useT, useLocale } from 'remix-intl';

// components
import { ClientProvider, IntlScript } from 'remix-intl';
import {
  Link, NavLink, SwitchLocaleLink, useNavigate
} from from '~/navigation'

// api for server
import { getT, getLocale } from 'remix-intl/server';

// utils
import { isClient, stringSimilarity, acceptLanguageMatcher } from 'remix-intl/utils';

i18next API

import { getIntlConfig } from 'remix-intl/i18n';

getIntlConfig().i18next.addResouceBundle;
getIntlConfig().i18next.dir;
getIntlConfig().i18next.getResouceBundle;

More i18next API: https://www.i18next.com/

Website and example

👉 https://remix-intl.tsdk.dev (WIP 🙇🏻‍♂️)

Support

  • Any questions, feel free create issues 🙌

Package Sidebar

Install

npm i remix-intl

Weekly Downloads

13

Version

0.0.12

License

MIT

Unpacked Size

91.9 kB

Total Files

34

Last publish

Collaborators

  • suhaotian