πŸ’‘
All the code from this tutorial is available in this Github Repo

Why Add i18n (Internationalization) to Your Remix Shopify App?

Here's the deal: if you're building Shopify apps, you're essentially building for a global marketplace. With merchants from over all over the world using Shopify, your app's success might depend on speaking their language - literally. After setting up your app for proper translations, adding a new language is as simple as translating JSON file.

Why use remix-i18next for Shopify apps?

After trying different approaches, I found remix-i18next to be the most robust solution for Remix applications. Here's why:

  • It's built specifically for Remix's server-first approach
  • Handles both client and server-side translations cleanly
  • Works seamlessly with Remix's data loading patterns

In this guide, I'll walk you through implementing i18n in your Remix Shopify app - from initial setup to handling those tricky edge cases I ran into (literally 4 hours of my life wasted on the silliest issue. My loss is your gain 🫑). Whether you're building a new app or adding translations to an existing one, you'll learn the practical steps to make your app truly international.

πŸ“¬
Building Shopify Apps? Join other Shopify app developers getting free tips and tutorials about app development, deployment, and optimization. Click Subscribe in the bottom right hand corner to get started 🀝

Installing and Configuring remix-i18next

The first thing that we need to do is install all of the required NPM packages that we will be using. The core piece of all of these packages is i18next. The rest of the packages are various helpers for remix specifics, automatic language detectors, and backends to load the translation files.

npm install remix-i18next i18next react-i18next i18next-browser-languagedetector i18next-http-backend i18next-fs-backend

Before we get too deep into the setup let’s talk about structure for our setup. These are the main files that we are going to be concerned about.

app/
  β”œβ”€β”€ i18next.server.ts    # i18next Server configuration
  β”œβ”€β”€ i18n.ts              # General i18n configuration
  β”œβ”€β”€ entry.client.tsx     # Creating to add i18n client config
  β”œβ”€β”€ entry.server.tsx     # Updating to add i18n server config
public/
  └── locales/
      β”œβ”€β”€ en/
      β”‚   └── common.json    # English translations
      └── fr/
      β”‚   └── common.json    # French translations

In the i18next.server.ts file we are going to be setting up the server side configuration. 18n will hold our general configuration that will be used both by the server and the frontend. entry.client.tsx will be updated to: detected the users language, pull the translation, and wrap our entire app in the I18nextProvider to give us access to the translation hook. entry.server.tsx will be updated to use react-i18next with our i18n.ts configuration file.

First let’s setup the configuration file: i18n.ts . Here we are able to set our supported languages, our fallback language, and our default namespace.

export default {
  supportedLngs: ['en', 'fr'],
  fallbackLng: 'en',
  defaultNS: 'common',
};

Then we can use this configuration to setup our server configuration in i18next.server.ts

// BUG: if you see an error about a `Top Level await` import form /cjs instead
// see: <https://github.com/i18next/i18next-fs-backend/issues/57>
// import Backend from 'i18next-fs-backend';
import Backend from 'i18next-fs-backend/cjs'
import { resolve } from 'node:path';
import { RemixI18Next } from 'remix-i18next/server';
import i18n from './i18n'; 

const i18next = new RemixI18Next({
  detection: {
    supportedLanguages: i18n.supportedLngs,
    fallbackLanguage: i18n.fallbackLng,
  },
  // Extending our default config file with server only fields
  i18next: {
    ...i18n,
    backend: {
      loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'),
    },
  },
	// Setting our our backend to load our translations from the file system via 
	// i18next-fs-backend
  plugins: [Backend],
});

export default i18next;

After our server side configuration is all setup we can move over to the frontend. The frontend configuration is done in the entry.client.tsx file. If you don’t yet have this file in your project you can create it now and add the following

import { RemixBrowser } from '@remix-run/react';
import { startTransition, StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import i18n from './i18n';
import i18next from 'i18next';
import { I18nextProvider, initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// BUG: if you see an error about a `Top Level await` import form /cjs instead
// see: <https://github.com/i18next/i18next-fs-backend/issues/57>
// import Backend from 'i18next-fs-backend';
import Backend from "i18next-http-backend";
import { getInitialNamespaces } from 'remix-i18next/client';

async function hydrate() {
  await i18next  // Setup i18next with the following packages via `.use()`
    .use(initReactI18next) 
    .use(LanguageDetector)
    .use(Backend)
    .init({
      ...i18n, // Extending our default config file with client only fields
      // detects the namespaces your routes rendered while SSR use
      ns: getInitialNamespaces(),
      backend: { loadPath: `/locales/{{lng}}/{{ns}}.json?v=${__APP_VERSION__}` },
      detection: {
        order: ['htmlTag'],
        caches: [],
      },
    });

  startTransition(() => {
    hydrateRoot(
      document,
      <I18nextProvider i18n={i18next}>
        <StrictMode>
          <RemixBrowser />
        </StrictMode>
      </I18nextProvider>,
    );
  });
}

if (window.requestIdleCallback) {
  window.requestIdleCallback(hydrate);
} else {
  // Safari doesn't support requestIdleCallback
  // <https://caniuse.com/requestidlecallback>
  window.setTimeout(hydrate, 1);
}

We are going to be relying on the server to detect the language of the user and then passing that to the frontend via the <html lang> tag. We are also adding the __APP_VERSION__ at the end of the loadPath . This will be explained in more detail later on but it allow us to fix any caching issues every time we release a new version and ensure that we are grabbing all of the new translation keys.

Then finally we can update our entry.server.tsx file with the following

import { PassThrough } from 'stream';
import { createReadableStreamFromReadable, type EntryContext } from '@remix-run/node';
import { RemixServer } from '@remix-run/react';
import { isbot } from 'isbot';
import { renderToPipeableStream } from 'react-dom/server';
import { createInstance } from 'i18next';
import i18next from './i18next.server';
import { I18nextProvider, initReactI18next } from 'react-i18next';
// BUG: if you see an error about a `Top Level await` import form /cjs instead
// see: <https://github.com/i18next/i18next-fs-backend/issues/57>
// import Backend from 'i18next-fs-backend';
import Backend from 'i18next-fs-backend/cjs'
import i18n from './i18n'; // your i18n configuration file
import { resolve } from 'node:path';
import { addDocumentResponseHeaders } from './shopify.server';

const ABORT_DELAY = 5000;

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
) {
  addDocumentResponseHeaders(request, responseHeaders);

  let callbackName = isbot(request.headers.get('user-agent')) ? 'onAllReady' : 'onShellReady';

  let instance = createInstance();
  let lng = await i18next.getLocale(request);
  let ns = i18next.getRouteNamespaces(remixContext);

  await instance
    .use(initReactI18next) 
    .use(Backend) 
    .init({
      ...i18n,
      lng,    // The locale we detected above
      ns,     // The namespaces the routes about to render wants to use
      backend: { loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json') },
    });

  return new Promise((resolve, reject) => {
    let didError = false;

    let { pipe, abort } = renderToPipeableStream(
      <I18nextProvider i18n={instance}>
        <RemixServer context={remixContext} url={request.url} />
      </I18nextProvider>,
      {
        [callbackName]: () => {
          let body = new PassThrough();
          const stream = createReadableStreamFromReadable(body);
          responseHeaders.set('Content-Type', 'text/html');

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

          pipe(body);
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          didError = true;

          console.error(error);
        },
      },
    );

    setTimeout(abort, ABORT_DELAY);
  });
}

The two most interesting part of the server configuration are.

  let lng = await i18next.getLocale(request);
  let ns = i18next.getRouteNamespaces(remixContext);

This allow us to automatically pull the language out of the request of the user. In our case, this will automatically grab the language that the users Shopify admin is using. We also get the namespace that the route requires. Breaking up the translation files into page based name spaces allows use to ensure that we are only sending the translations that are needed. We then pass both of these values to the i18n instance.

After that, we are finally ready to use our setup in our app/root.tsx file

import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  json,
  useLoaderData,
} from '@remix-run/react';
import { useChangeLanguage } from 'remix-i18next/react';
import { useTranslation } from 'react-i18next';
import i18next from './i18next.server';
import type { LoaderFunctionArgs } from '@remix-run/node';

export async function loader({ request }: LoaderFunctionArgs) {
	// grab the locale and return it to the client
  const locale = await i18next.getLocale(request);
  return json({ locale });
}

export const handle = {
	 // the namespace that this page will need
  i18n: 'common',
};

export default function App() {
  // Get the locale from the loader
  const { locale } = useLoaderData<typeof loader>();
  const { i18n } = useTranslation();

  // This hook will change the i18n instance language to the current locale
  // detected by the loader, this way, when we do something to change the
  // language, this locale will change and i18next will load the correct
  // translation files
  useChangeLanguage(locale);

  return (
    <html lang={locale} dir={i18n.dir()}>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        <link rel="preconnect" href="https://cdn.shopify.com/" />
        <link rel="stylesheet" href="https://cdn.shopify.com/static/fonts/inter/v4/styles.css" />
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

Adding Translations

After all of that setup we can finally add our translation files. Based on the structure above and our configuration code, we will be storing these files in the /public/locales directory. In this directory we can add a new directory for each of the languages that our Shopify app supports.

public/
  └── locales/
      β”œβ”€β”€ en/
      β”‚   └── common.json    # English translations
      └── fr/
      β”‚   └── common.json    # French translations

For the sake of simplicity we are only going to use a single namespace common in this blog post. You could add a namespace for each of the pages for your remix app. See i18next namespace documentation for more details.

In our translation json files we are simply specifying a key that we will use in our code and the translation value that will replace that key.

{
    "global": {
      "greeting": "Hello",
      "button": {
        "save": "Save",
        "discard": "Discard"
      }
  }
}

Using i18next Server side with Remix

With all of setup and configuration out of the way we can finally use i18next to start translating the strings. On the server you can use the following to grab the appropriate translation for the given value.

import i18next from './../i18next.server';

const t = await i18next.getFixedT(request, 'common');

const value = t('global.greeting');
console.log(value); // hello

To get nested keys you are able to use . to access the nested object. For example: global.button.save

Using i18next Client side with Remix

Since we wrapped our app with the I18nextProvider we have access to the useTranslation() hook. This hook gives us the t() that we can use to translate all of our strings.


export default function Index() {
  const { t } = useTranslation();
  const saveButton = t("global.button.save");
  console.log({ saveButton });

  return (
    <Page>
      <TitleBar title="Remix app template"></TitleBar>
      <BlockStack gap="500">
        <Layout>
          <Layout.Section>
            <Card>
              <BlockStack gap="500">
                <BlockStack gap="200">
                  <Text as="h2" variant="headingMd">
                    πŸ‘‹ {t("global.greeting")}, i18n Translation
                  </Text>

                  <ButtonGroup>
                    <Button variant="secondary" onClick={() => {}}>
                      {t("global.button.discard")}
                    </Button>
                    <Button variant="primary" onClick={() => {}}>
                      {t("global.button.save")}
                    </Button>
                  </ButtonGroup>
                </BlockStack>
              </BlockStack>
            </Card>
          </Layout.Section>
        </Layout>
      </BlockStack>
    </Page>
  );
}

Caching Issue

While I was originally implementing i18n in my app Redirect Ninja - Easy 404 Fix I was facing some very annoying caching issues. Whenever I deployed a new version of my app that had some updated translations the keys were displaying on the server instead of the translations. This only happened on production and never when I was running my app locally. I eventually figured out that this was a caching issue because opening the app in different incognito window displayed the translations properly. After doing some research I found that the best fix for me was simply including the version from the package.json as a query parameter on the loadPath url. Every time I release a new version of my app the version number gets bumped. This causes the URL to be treated as a completely new request and the cache is ignored. More info here: https://stackoverflow.com/questions/74752099/react-i18next-and-cached-translation-files

backend: { loadPath: `/locales/{{lng}}/{{ns}}.json?v=${__APP_VERSION__}` }

Tools and Testing

You can change the language of your browser to ensure that translations are getting properly loaded. In Google Chrome you can go to settings > languages > Add Language and then move the new language to the top of the list.

Rendered app with French translation

Changing Shopify Account Language

You can also change the language for your Shopify Admin user by going to your store settings, clicking on Users and Permissions , clicking on View account settings and then selecting a different preferred language.

There is also a really great Visual Studio Code extension called i18n-ally that I highly recommend. It allows you to see the the translation in place of the keys in your editor for your default language, reporting missing translations, and whole bunch more.

Next Steps

Now you have a fully internationalized Shopify app with:

  • Automatic language detection from Shopify admin
  • Efficient translation loading
  • Proper server and client handling
  • Cache-busting for production deployments

You can extend this setup by:

The most important thing is to start with a solid foundation, which you now have. Every new language you add will follow the same pattern: create a new language folder, add your translation files, and you're ready to go.

Stay Updated with Shopify App Development

If you found this guide helpful, there's more where that came from! I write about all aspects of Shopify app development

πŸ“¬
Thanks for making it this far in the tutorial! Subscribe to the newsletter for more Shopify App development tips & tutorials. Click Subscribe in the bottom right hand corner to get started! 🀝

Get practical development tips and tutorials delivered straight to your inbox. Join other Shopify app developers learning to build better apps.

If you have any questions feel free to contact me on Twitter @jeff_codes or on BlueSky @jeff-codes.bsky.social. Join my newsletter to get notified of all upcoming Shopify App tutorials.

Errors & Gotchas

import backend from 'i18next-fs-backend/cjs'
  define: {
    '__APP_VERSION__': JSON.stringify(process.env.npm_package_version),
  },

export default defineConfig({
  server: {
    port: Number(process.env.PORT || 3000),
    hmr: hmrConfig,
    fs: {
      allow: ["app", "node_modules"],
    },
  },
  define: {
    '__APP_VERSION__': JSON.stringify(process.env.npm_package_version),
  },
  plugins: [
    remix({
      // ⬇️ The output format of the server build. Defaults to "esm".
      ignoredRouteFiles: ["**/.*"],
    }),
    tsconfigPaths(),
  ],
  build: {
    assetsInlineLimit: 0,
  },
}) satisfies UserConfig;
    • Fix the typescript error. Create file vite-env.d.ts and add the following
declare const __APP_VERSION__: string

Translating Remix Shopify Applications with i18n

Learn how to add multiple languages to your Shopify Remix app using i18n. This step-by-step guide covers setting up remix-i18next, handling translations on both client and server, and avoiding common pitfalls. Perfect for Shopify app developers looking to reach a global merchant base.