# How to Translate a Static HTML Website (No Framework Needed)

> Translate a plain HTML site with data-i18n attributes, JSON files and a tiny runtime, then build one page per language for SEO. Tested code, plus the automatic way.
- **Author**: Mohit Sapat
- **Published**: 2026-10-16
- **URL**: https://www.babelize.co/blogs/translate-static-html-website

---

<p class="lead">
    A static site is just HTML, CSS and a little JavaScript, which makes it easy to build and surprisingly awkward to translate. There is no framework to lean on, so you choose how the text gets swapped and how search engines find each language. This guide builds both pieces from scratch with tested code: a small runtime that translates the page in the browser, and a build step that writes a real page per language for SEO. Then it shows the same result generated automatically.
</p>

<h2>Two Ways to Translate a Static Site</h2>

<table>
    <thead>
        <tr>
            <th></th>
            <th>Translate in the browser</th>
            <th>One HTML file per language</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>How it works</td>
            <td>One page; a script swaps the text after load</td>
            <td><code>/</code>, <code>/fr/</code>, <code>/ar/</code> are separate pages built from the same source</td>
        </tr>
        <tr>
            <td>Language URL</td>
            <td>Same URL, choice remembered in the browser</td>
            <td>Each language has its own URL</td>
        </tr>
        <tr>
            <td>Search engines</td>
            <td>See the source language</td>
            <td>Index every language, linked with hreflang</td>
        </tr>
        <tr>
            <td>Best for</td>
            <td>Tools, dashboards, pages behind a login</td>
            <td>Marketing sites, docs, anything that should rank</td>
        </tr>
    </tbody>
</table>

<p>
    Both start the same way: mark the text, then move it into one JSON file per language. That is most of the work, and it is shared.
</p>

<h2>Step 1: Mark the Text With data-i18n</h2>

<p>
    Give every piece of visible text a key. Text content gets <code>data-i18n</code>; attributes that users see, such as <code>placeholder</code>, <code>alt</code> and the meta description's <code>content</code>, get <code>data-i18n-attr</code> with an <code>attribute:key</code> pair. Keep the English text in the HTML so the page still works if JavaScript fails.
</p>

<pre><code>&lt;!doctype html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
  &lt;meta charset=&quot;utf-8&quot;&gt;
  &lt;title data-i18n=&quot;meta.title&quot;&gt;Acme Notes - Simple note-taking&lt;/title&gt;
  &lt;meta name=&quot;description&quot; data-i18n-attr=&quot;content:meta.description&quot; content=&quot;Write, tag and find your notes in seconds.&quot;&gt;
  &lt;script src=&quot;/i18n.js&quot; defer&gt;&lt;/script&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;nav&gt;
    &lt;select id=&quot;language-picker&quot; aria-label=&quot;Language&quot;&gt;
      &lt;option value=&quot;en&quot;&gt;English&lt;/option&gt;
      &lt;option value=&quot;fr&quot;&gt;Français&lt;/option&gt;
      &lt;option value=&quot;ar&quot;&gt;العربية&lt;/option&gt;
    &lt;/select&gt;
  &lt;/nav&gt;
  &lt;h1 data-i18n=&quot;hero.title&quot;&gt;Your notes, finally organized&lt;/h1&gt;
  &lt;p data-i18n=&quot;hero.subtitle&quot;&gt;Write, tag and find your notes in seconds.&lt;/p&gt;
  &lt;input type=&quot;email&quot; data-i18n-attr=&quot;placeholder:signup.placeholder&quot; placeholder=&quot;you@example.com&quot;&gt;
  &lt;button data-i18n=&quot;signup.button&quot;&gt;Start free&lt;/button&gt;
  &lt;img src=&quot;/screenshot.png&quot; data-i18n-attr=&quot;alt:hero.imageAlt&quot; alt=&quot;The Acme Notes editor&quot;&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre>

<p>
    Do not forget the parts nobody sees while designing: the <code>&lt;title&gt;</code>, the meta description, <code>alt</code> text and <code>aria-label</code>s. They show up in search results, browser tabs and screen readers.
</p>

<h2>Step 2: One JSON File per Language</h2>

<p>
    Put the strings in <code>locales/en.json</code>, <code>locales/fr.json</code> and so on, with the same keys in every file. Nested keys keep related strings together; <code>hero.title</code> in the HTML means <code>hero</code> &rarr; <code>title</code> in the JSON. The French file:
</p>

<pre><code>{
  &quot;meta&quot;: {
    &quot;title&quot;: &quot;Acme Notes - Prise de notes simple&quot;,
    &quot;description&quot;: &quot;Écrivez, étiquetez et retrouvez vos notes en quelques secondes.&quot;
  },
  &quot;hero&quot;: {
    &quot;title&quot;: &quot;Vos notes, enfin organisées&quot;,
    &quot;subtitle&quot;: &quot;Écrivez, étiquetez et retrouvez vos notes en quelques secondes.&quot;,
    &quot;imageAlt&quot;: &quot;L&#x27;éditeur d&#x27;Acme Notes&quot;
  },
  &quot;signup&quot;: {
    &quot;placeholder&quot;: &quot;vous@exemple.com&quot;,
    &quot;button&quot;: &quot;Commencer gratuitement&quot;
  }
}</code></pre>

<p>
    Translate only the values, never the keys. For the file format itself (nesting, placeholders, plurals), see <a href="/blogs/json-translation-files-explained/">JSON translation files explained</a>.
</p>

<h2>Step 3: A Small Runtime That Swaps the Text</h2>

<p>
    This script picks a language, loads its JSON file and replaces the marked text and attributes. It checks, in order: a language the visitor chose before, then the browser's preferred languages, then English. Only an explicit choice is saved, so a language detected automatically never overrides a later change in the browser's settings. It also sets <code>lang</code> and, for Arabic, Hebrew, Persian and Urdu, <code>dir="rtl"</code> on the <code>&lt;html&gt;</code> element.
</p>

<pre><code>const SUPPORTED = [&quot;en&quot;, &quot;fr&quot;, &quot;ar&quot;];
const DEFAULT_LANG = &quot;en&quot;;
const RTL = new Set([&quot;ar&quot;, &quot;he&quot;, &quot;fa&quot;, &quot;ur&quot;]);

// &quot;hero.title&quot; -&gt; messages.hero.title
function lookup(messages, key) {
  return key.split(&quot;.&quot;).reduce((node, part) =&gt; node?.[part], messages);
}

function pickLanguage() {
  const saved = localStorage.getItem(&quot;lang&quot;);
  if (SUPPORTED.includes(saved)) return saved;
  for (const tag of navigator.languages ?? [navigator.language]) {
    const base = tag.toLowerCase().split(&quot;-&quot;)[0];
    if (SUPPORTED.includes(base)) return base;
  }
  return DEFAULT_LANG;
}

async function setLanguage(lang, { remember = false } = {}) {
  const response = await fetch(`/locales/${lang}.json`);
  const messages = await response.json();

  for (const el of document.querySelectorAll(&quot;[data-i18n]&quot;)) {
    const text = lookup(messages, el.dataset.i18n);
    if (typeof text === &quot;string&quot;) el.textContent = text;
  }

  // data-i18n-attr=&quot;placeholder:signup.placeholder; title:signup.hint&quot;
  for (const el of document.querySelectorAll(&quot;[data-i18n-attr]&quot;)) {
    for (const pair of el.dataset.i18nAttr.split(&quot;;&quot;)) {
      const [attr, key] = pair.split(&quot;:&quot;).map((s) =&gt; s.trim());
      const text = lookup(messages, key);
      if (attr &amp;&amp; typeof text === &quot;string&quot;) el.setAttribute(attr, text);
    }
  }

  document.documentElement.lang = lang;
  document.documentElement.dir = RTL.has(lang) ? &quot;rtl&quot; : &quot;ltr&quot;;
  // Only an explicit choice is remembered, so a detected language never overrides a later browser setting.
  if (remember) localStorage.setItem(&quot;lang&quot;, lang);

  const picker = document.getElementById(&quot;language-picker&quot;);
  if (picker) picker.value = lang;
}

document.addEventListener(&quot;DOMContentLoaded&quot;, () =&gt; {
  document.getElementById(&quot;language-picker&quot;)
    ?.addEventListener(&quot;change&quot;, (event) =&gt; setLanguage(event.target.value, { remember: true }));
  setLanguage(pickLanguage());
});</code></pre>

<p>
    We ran it in a simulated browser (jsdom) with four visitors. A French-Canadian browser (<code>fr-CA</code>) got French, including the title, meta description, placeholder and image alt text. Picking Arabic switched the page to right-to-left and saved the choice. A German browser returning with Arabic saved got Arabic. A browser with only unsupported languages (<code>de</code>, <code>ja</code>) got English and saved nothing.
</p>

<p>
    Two things this approach cannot avoid. The page renders in English for a moment before the translation arrives, and search engines index the English text. For a tool behind a login, that is fine. For pages that should rank in other languages, add step 4.
</p>

<h2>Step 4: Build One Page per Language for SEO</h2>

<p>
    Search engines index each language best when it has its own URL with the translated text already in the HTML. The same <code>data-i18n</code> markup and JSON files can produce that at build time. This Node script (it needs <code>jsdom</code>: <code>npm install jsdom</code>) writes <code>dist/index.html</code> in English plus <code>dist/fr/index.html</code> and <code>dist/ar/index.html</code>, each with its own <code>lang</code> and <code>dir</code>, a canonical URL, <code>hreflang</code> links to every language and plain links for switching:
</p>

<pre><code>// Pre-render one HTML file per language so search engines see translated text.
// Usage: node build.mjs   (reads site/, writes dist/)
import fs from &quot;node:fs&quot;;
import path from &quot;node:path&quot;;
import { JSDOM } from &quot;jsdom&quot;;

const SITE_URL = &quot;https://example.com&quot;;
const LANGS = [&quot;en&quot;, &quot;fr&quot;, &quot;ar&quot;];
const DEFAULT_LANG = &quot;en&quot;;
const RTL = new Set([&quot;ar&quot;, &quot;he&quot;, &quot;fa&quot;, &quot;ur&quot;]);
const LABELS = { en: &quot;English&quot;, fr: &quot;Français&quot;, ar: &quot;العربية&quot; };

const lookup = (messages, key) =&gt; key.split(&quot;.&quot;).reduce((node, part) =&gt; node?.[part], messages);
const urlFor = (lang) =&gt; (lang === DEFAULT_LANG ? `${SITE_URL}/` : `${SITE_URL}/${lang}/`);
const source = fs.readFileSync(&quot;site/index.html&quot;, &quot;utf8&quot;);

for (const lang of LANGS) {
  const messages = JSON.parse(fs.readFileSync(`site/locales/${lang}.json`, &quot;utf8&quot;));
  const dom = new JSDOM(source);
  const doc = dom.window.document;

  for (const el of doc.querySelectorAll(&quot;[data-i18n]&quot;)) {
    const text = lookup(messages, el.dataset.i18n);
    if (typeof text !== &quot;string&quot;) throw new Error(`${lang}: missing ${el.dataset.i18n}`);
    el.textContent = text;
  }
  for (const el of doc.querySelectorAll(&quot;[data-i18n-attr]&quot;)) {
    for (const pair of el.dataset.i18nAttr.split(&quot;;&quot;)) {
      const [attr, key] = pair.split(&quot;:&quot;).map((s) =&gt; s.trim());
      const text = lookup(messages, key);
      if (typeof text !== &quot;string&quot;) throw new Error(`${lang}: missing ${key}`);
      el.setAttribute(attr, text);
    }
  }

  doc.documentElement.lang = lang;
  doc.documentElement.dir = RTL.has(lang) ? &quot;rtl&quot; : &quot;ltr&quot;;

  // The runtime loader isn&#x27;t needed on pre-rendered pages.
  doc.querySelector(&#x27;script[src=&quot;/i18n.js&quot;]&#x27;)?.remove();

  // Swap the &lt;select&gt; for plain links: each language is its own URL now.
  const nav = doc.querySelector(&quot;nav&quot;);
  nav.innerHTML = LANGS.map(
    (l) =&gt; `&lt;a href=&quot;${urlFor(l)}&quot; hreflang=&quot;${l}&quot; lang=&quot;${l}&quot;${l === lang ? &#x27; aria-current=&quot;page&quot;&#x27; : &quot;&quot;}&gt;${LABELS[l]}&lt;/a&gt;`
  ).join(&quot; &quot;);

  // Canonical + hreflang alternates, including x-default.
  const head = doc.head;
  head.insertAdjacentHTML(&quot;beforeend&quot;, `&lt;link rel=&quot;canonical&quot; href=&quot;${urlFor(lang)}&quot;&gt;`);
  for (const l of LANGS) head.insertAdjacentHTML(&quot;beforeend&quot;, `&lt;link rel=&quot;alternate&quot; hreflang=&quot;${l}&quot; href=&quot;${urlFor(l)}&quot;&gt;`);
  head.insertAdjacentHTML(&quot;beforeend&quot;, `&lt;link rel=&quot;alternate&quot; hreflang=&quot;x-default&quot; href=&quot;${urlFor(DEFAULT_LANG)}&quot;&gt;`);

  const outDir = lang === DEFAULT_LANG ? &quot;dist&quot; : path.join(&quot;dist&quot;, lang);
  fs.mkdirSync(outDir, { recursive: true });
  fs.writeFileSync(path.join(outDir, &quot;index.html&quot;), dom.serialize());
  console.log(`wrote ${path.join(outDir, &quot;index.html&quot;)}`);
}</code></pre>

<p>
    The script stops with an error if any language is missing a key, for example <code>fr: missing signup.button</code>, rather than shipping a French page with an English button. Upload <code>dist/</code> to any static host. The <code>hreflang</code> links tell search engines the pages are translations of each other; the <a href="/blogs/hreflang-multilingual-seo-guide/">hreflang guide</a> covers the rules, and <a href="/blogs/locale-codes-bcp-47-explained/">BCP 47 language codes</a> explains which codes to use.
</p>

<h2>Details That Break Translated Static Sites</h2>

<ul>
    <li><strong>Right-to-left languages.</strong> Setting <code>dir="rtl"</code> flips the page, but margins and paddings written as <code>left</code>/<code>right</code> do not flip. Use logical properties such as <code>margin-inline-start</code>; see <a href="/blogs/rtl-support-css-logical-properties/">RTL support with CSS logical properties</a>.</li>
    <li><strong>Longer text.</strong> Translations often run longer than English, and short labels grow the most: German UI text measured about 30% longer in <a href="/blogs/translate-saas-into-german/">our German guide</a>. Leave room in buttons and navigation.</li>
    <li><strong>Dates, numbers and prices.</strong> Do not put them in the JSON as finished strings. Format them in JavaScript with <code>Intl.DateTimeFormat</code> and <code>Intl.NumberFormat</code> for the current language; see <a href="/blogs/javascript-intl-api-dates-numbers-currencies/">the Intl API guide</a>.</li>
    <li><strong>Text built in JavaScript.</strong> Messages created in your scripts (validation errors, toasts) are not in the HTML, so <code>data-i18n</code> never sees them. Look them up from the same JSON file instead of hardcoding English.</li>
    <li><strong>Pages in subfolders.</strong> Load the runtime and the JSON files with root-relative paths (<code>/i18n.js</code>, <code>/locales/</code>) or paths relative to the page, so <code>blog/post.html</code> finds them too.</li>
</ul>

<h2>Doing It Automatically With Babelize</h2>

<p>
    Marking every string, inventing keys and keeping the JSON files in sync is the slow part, and it starts again whenever the copy changes. The <a href="https://docs.babelize.co/cli/translate">Babelize CLI</a> does steps 1 to 3 on an existing site. Run it at the root of a git repository with your HTML files and a clean working tree. It translates with your own LLM API key (Anthropic, OpenAI, Gemini, Groq or OpenRouter, read from the environment) or a Babelize API key:
</p>

<pre><code>npx @babelize/cli translate --to fr,de,ar</code></pre>

<p>
    It finds the text in every HTML page (headings, paragraphs, buttons, links, <code>alt</code>, <code>title</code>, <code>placeholder</code> and <code>aria-label</code> attributes, the <code>&lt;title&gt;</code> and SEO meta tags), adds <code>data-i18n</code> keys, writes <code>locales/en.json</code> and translated files for each language, and adds an <code>i18n.js</code> runtime with no dependencies. Here is what it changed on the example page from step 1, before we added any markup by hand:
</p>

<pre><code>&lt;title data-i18n=&quot;indexhtml.acmeNotesSimpleNoteTaking&quot;&gt;Acme Notes - Simple note-taking&lt;/title&gt;
  &lt;meta name=&quot;description&quot; content=&quot;Write, tag and find your notes in seconds.&quot; data-i18n-content=&quot;indexhtml.writeTagAndFindYour&quot;&gt;
  &lt;h1 data-i18n=&quot;indexhtml.yourNotesFinallyOrganized&quot;&gt;Your notes, finally organized&lt;/h1&gt;
  &lt;p data-i18n=&quot;indexhtml.writeTagAndFindYour&quot;&gt;Write, tag and find your notes in seconds.&lt;/p&gt;
  &lt;input type=&quot;email&quot; placeholder=&quot;Your email address&quot; data-i18n-placeholder=&quot;indexhtml.yourEmailAddress&quot;&gt;
  &lt;button data-i18n=&quot;indexhtml.startFree&quot;&gt;Start free&lt;/button&gt;
  &lt;img src=&quot;screenshot.png&quot; alt=&quot;The Acme Notes editor&quot; data-i18n-alt=&quot;indexhtml.theAcmeNotesEditor&quot;&gt;
  &lt;a href=&quot;blog/first-post.html&quot; data-i18n=&quot;indexhtml.readOurBlog&quot;&gt;Read our blog&lt;/a&gt;
  &lt;script src=&quot;i18n.js&quot;&gt;&lt;/script&gt;</code></pre>

<p>
    The keys are generated from the page name and the text, and the English file looks like this (excerpt):
</p>

<pre><code>{
  &quot;_meta&quot;: {
    &quot;dir&quot;: &quot;ltr&quot;,
    &quot;language&quot;: &quot;en&quot;
  },
  &quot;indexhtml&quot;: {
    &quot;yourNotesFinallyOrganized&quot;: &quot;Your notes, finally organized&quot;,
    &quot;startFree&quot;: &quot;Start free&quot;,
    &quot;yourEmailAddress&quot;: &quot;Your email address&quot;
  }
}</code></pre>

<p>
    The runtime picks the language from a <code>?lang=fr</code> URL parameter, then the visitor's earlier choice, then the browser, and falls back to the source language. Add <code>&lt;div data-babelize-switcher&gt;&lt;/div&gt;</code> where you want a language picker. Pages in subfolders get a script path that points back to the root, so the whole site translates.
</p>

<p>
    Everything lands in your working tree as a normal git diff, so you review it before committing, and a lockfile means the next run only translates new or changed strings. To see the result on your own site without an API key, run it with <code>--provider pseudo</code>, which fills the files with obviously fake text such as <code>⟦Stárt fréé⟧</code> instead of calling a translation model (do not ship that output). <a href="https://docs.babelize.co/cli/check"><code>babelize check</code></a> then reports missing keys and broken placeholders in every language, locally and in CI.
</p>

<p>
    The Babelize runtime translates in the browser, like step 3. If the pages need to rank in every language, keep the generated JSON files and run them through a build step like step 4.
</p>

<h2>Frequently Asked Questions</h2>

<h3>Can I translate a static HTML website without a framework?</h3>
<p>
    Yes. Mark the text with <code>data-i18n</code> attributes, store the translations in one JSON file per language, and either swap the text in the browser with a small script or generate one HTML file per language at build time. No framework or npm package is needed for the browser version.
</p>

<h3>Will Google index a static site translated with JavaScript?</h3>
<p>
    Not reliably. When the text is swapped in the browser, there is one URL per page and it serves the source language, so search engines mostly see that. For pages that should rank in other languages, generate a separate HTML file per language with its own URL and link them with hreflang.
</p>

<h3>Should I use Google Translate's website widget instead?</h3>
<p>
    Machine translation in the visitor's browser needs no setup, but you cannot review or edit the result, search engines do not index it as your pages, and it knows nothing about your product's terminology. Translating your own files gives you control over every string.
</p>

<h3>How do I add a language switcher to a static site?</h3>
<p>
    In the browser approach, use a <code>&lt;select&gt;</code> that calls your <code>setLanguage</code> function and saves the choice in <code>localStorage</code>. With one page per language, use plain links to each language's URL, so the switcher works without JavaScript and search engines can follow it.
</p>

<h3>How do I handle Arabic and Hebrew on a static site?</h3>
<p>
    Set <code>dir="rtl"</code> and the right <code>lang</code> on the <code>&lt;html&gt;</code> element for those languages, and write CSS with logical properties such as <code>margin-inline-start</code> so the layout mirrors correctly.
</p>
---
- [All articles](https://www.babelize.co/blogs)