I added 5 languages and Arabic RTL to a React + Tailwind landing page. Here is what I learned
I recently built a SaaS landing page template with React, Vite and Tailwind CSS, and I wanted it to work in English, German, French, Chinese and Arabic. Arabic was the interesting one, because it needs a right-to-left (R
I recently built a SaaS landing page template with React, Vite and Tailwind CSS, and I wanted it to work in English, German, French, Chinese and Arabic. Arabic was the interesting one, because it needs a right-to-left (RTL) layout, not just translated text.
This post covers the setup I ended up with and the mistakes I made along the way. I am still learning, so if you know a better way to do any of this, please tell me in the comments.
You can try the result here: live demo
The stack
- React + Vite
- Tailwind CSS (v3.3 or newer, because of logical properties)
-
i18next,react-i18next,i18next-browser-languagedetector
npm install i18next react-i18next i18next-browser-languagedetector
1. Folder structure
I keep one JSON file per language inside src, not inside public:
src/
i18n/config.js
locales/
en/translation.json
de/translation.json
fr/translation.json
zh/translation.json
ar/translation.json
Why src and not public? Vite does not let you import JavaScript modules from public. If you keep the files there you need an HTTP backend plugin and Suspense. Putting the JSON in src lets you import it directly, which is simpler for a small project.
2. The i18n config
// src/i18n/config.js
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import en from "../locales/en/translation.json";
import de from "../locales/de/translation.json";
import fr from "../locales/fr/translation.json";
import zh from "../locales/zh/translation.json";
import ar from "../locales/ar/translation.json";
const RTL_LANGUAGES = ["ar"];
const applyDirection = (lng = "en") => {
const base = lng.split("-")[0];
document.documentElement.dir = RTL_LANGUAGES.includes(base) ? "rtl" : "ltr";
document.documentElement.lang = base;
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
de: { translation: de },
fr: { translation: fr },
zh: { translation: zh },
ar: { translation: ar },
},
fallbackLng: "en",
interpolation: { escapeValue: false }, // React already escapes
});
applyDirection(i18n.language);
i18n.on("languageChanged", applyDirection);
export default i18n;
Import it once in main.jsx (import "./i18n/config";) and you are ready.
Two details that matter:
-
escapeValue: falseis needed with React, otherwise characters like apostrophes can show up escaped when you use{{variables}}. - Setting
diron<html>is what flips the whole page. I update it on startup and every time the language changes.
3. Using translations in components
import { useTranslation } from "react-i18next";
export default function Hero() {
const { t } = useTranslation();
return <h1>{t("hero.title")}</h1>;
}
For text with a variable:
{ "checkout": { "subtitle": "You are subscribing to the {{plan}} plan." } }
t("checkout.subtitle", { plan: "Pro" })
For lists (like testimonials or pricing features), use returnObjects:
const reviews = t("testimonials.reviews", { returnObjects: true });
For a sentence that contains links, use the Trans component so translators can reorder the words:
{ "agree": "By signing up you agree to our <terms>Terms</terms> and <privacy>Privacy Policy</privacy>." }
<Trans
i18nKey="auth.register.agree"
components={{
terms: <Link to="/term" className="underline" />,
privacy: <Link to="/privacy" className="underline" />,
}}
/>
4. Making the layout work in RTL
Changing dir="rtl" flips text direction and flex rows automatically, but your Tailwind classes can still break the layout if they use physical directions like ml-4, pl-6 or left-0.
Switch to logical classes:
| Instead of | Use |
|---|---|
ml-* / mr-*
|
ms-* / me-*
|
pl-* / pr-*
|
ps-* / pe-*
|
left-* / right-*
|
start-* / end-*
|
text-left |
text-start |
border-l |
border-s |
Directional icons (like a back arrow) need to be flipped:
<ArrowLeft className="rtl:rotate-180" />
5. Mistakes that cost me time
1. Saving JSON as UTF-16 or with a BOM. My Vite build threw Unexpected token on a translation file. The editor had saved it with a BOM. Save every JSON file as UTF-8 without BOM.
2. Shadowing t inside .map(). I had code like this:
tasks.map((t) => <li key={t.id}>{t.label}</li>)
The t inside map hides the translation function. Rename it:
tasks.map((task) => <li key={task.id}>{task.label}</li>)
3. Storing translated error text in state. If you do setError(t("errors.tooShort")), the message stays in the old language when the user switches language. I store the key instead and translate when rendering:
setError("auth.errors.tooShort");
// ...
{error && <p>{t(error)}</p>}
4. Numbers, prices and emails in RTL. A price like $49.00 can look reversed in Arabic. Wrap values that should stay left-to-right:
<span dir="ltr">$49.00</span>
I do the same for card number and expiry inputs.
5. Mixed English and Arabic in one line. A footer line such as © 2026 Brand Name. All rights reserved. can jumble the order in RTL. Putting the Latin part in its own dir="ltr" element fixes it.
6. Different array lengths across languages. If reviews has 3 items in English and 2 in German, the page crashes or shows empty cards. Keep arrays identical in every language file.
6. A tip for keeping files in sync
Copy the English file, translate, and never rename keys in only one language. I wrote a small script to compare the key lists of all five files and to check that placeholders like {{plan}} and tags like <terms> match everywhere. It caught several mistakes before users could.
What I would do differently
- Add a script or lint step to compare translation keys from day one.
- Get native speakers to review the German, French, Chinese and Arabic text. My translations are a good starting point, not a final product.
- Translate legal pages with a professional. I left them in English in the demo.
Wrap up
Adding i18n was easier than I expected. RTL was the part that needed the most care, but logical Tailwind classes handled most of it.
If you want to see it working, the live demo has a language switcher in the navbar. Switch to Arabic and watch the layout flip.
If you would like the full source, the template is available here: [add your Gumroad link]
What is your approach to RTL in React? Did I miss any gotchas? Let me know below.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.