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.
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.
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:
- Adding more languages to your
supportedLngs
array - Creating namespace files for different pages or sections of your app
- Setting up automated translation file generation
- Dynamic translations with variables
- Formatting
- Explore all of the i18next docs
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
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
Top-level await is not available in the configured target environment
- See https://github.com/i18next/i18next-fs-backend/issues/57
- Simply update imports to
/cjs
fori18next-fs-backend
import backend from 'i18next-fs-backend/cjs'
ReferenceError: __APP_VERSION__ is not defined
- See https://stackoverflow.com/questions/67194082/how-can-i-display-the-current-app-version-from-package-json-to-the-user-using-vi
- Add following to your
vite.config.js
file:
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.