DEV Community

Cover image for I added 5 languages and Arabic RTL to a React + Tailwind landing page. Here is what I learned
Muhammad Abdullah
Muhammad Abdullah

Posted on

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 (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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Import it once in main.jsx (import "./i18n/config";) and you are ready.

Two details that matter:

  • escapeValue: false is needed with React, otherwise characters like apostrophes can show up escaped when you use {{variables}}.
  • Setting dir on <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>;
}
Enter fullscreen mode Exit fullscreen mode

For text with a variable:

{ "checkout": { "subtitle": "You are subscribing to the {{plan}} plan." } }
Enter fullscreen mode Exit fullscreen mode
t("checkout.subtitle", { plan: "Pro" })
Enter fullscreen mode Exit fullscreen mode

For lists (like testimonials or pricing features), use returnObjects:

const reviews = t("testimonials.reviews", { returnObjects: true });
Enter fullscreen mode Exit fullscreen mode

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>." }
Enter fullscreen mode Exit fullscreen mode
<Trans
  i18nKey="auth.register.agree"
  components={{
    terms: <Link to="/term" className="underline" />,
    privacy: <Link to="/privacy" className="underline" />,
  }}
/>
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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>)
Enter fullscreen mode Exit fullscreen mode

The t inside map hides the translation function. Rename it:

tasks.map((task) => <li key={task.id}>{task.label}</li>)
Enter fullscreen mode Exit fullscreen mode

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>}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)