IntlPull
Tutorial
18 min read

How to Translate a Web Page: The Ultimate Developer Guide (2026)

Step-by-step guide to verifying and implementing website translation. From simple widgets to production-ready React, Next.js, and Vue i18n systems.

IntlPull Team
IntlPull Team
03 Feb 2026, 11:44 AM [PST]
On this page
Summary

Step-by-step guide to verifying and implementing website translation. From simple widgets to production-ready React, Next.js, and Vue i18n systems.

The Levels of Website Translation

Translating a website isn't one thing. It's a spectrum from "quick hack" to "production-grade internationalization system."

Here's what that looks like:

LevelEffortQualityUse Case
1. Google Translate Widget5 minutesPoorTemporary, testing demand
2. Manual Static Pages1-2 daysGoodSmall sites (5-10 pages)
3. JSON Files + Library3-5 daysGreatMost websites
4. CMS Integration1-2 weeksGreatMarketing sites
5. Full i18n Platform2-4 weeksExcellentSaaS, high-traffic sites

This guide covers all five approaches with code examples.

Level 1: Google Translate Widget (The 5-Minute Hack)

When to use: You want to test if a market is viable before investing in real translation.

Pros:

  • Zero code
  • Supports 100+ languages
  • Free

Cons:

  • Translation quality is mediocre
  • No SEO value (crawlers see original language)
  • Looks unprofessional
  • Translations aren't cached (slow)

Implementation

Add this to your <body>:

HTML
1<div id="google_translate_element"></div>
2
3<script type="text/javascript">
4  function googleTranslateElementInit() {
5    new google.translate.TranslateElement(
6      {
7        pageLanguage: 'en',
8        includedLanguages: 'es,fr,de,zh-CN,ja',
9        layout: google.translate.TranslateElement.InlineLayout.SIMPLE
10      },
11      'google_translate_element'
12    );
13  }
14</script>
15
16<script type="text/javascript" src="//translate.google.com/translate_a/element.js?cb=googleTranslateElementInit"></script>

That's it. Users see a dropdown to pick languages.

Styling the widget:

CSS
1/* Hide Google's branding */
2.goog-te-banner-frame {
3  display: none !important;
4}
5
6/* Style the dropdown */
7.goog-te-combo {
8  padding: 8px 12px;
9  border: 1px solid #e2e8f0;
10  border-radius: 6px;
11  font-size: 14px;
12}

Why This Sucks for Production

  1. SEO disaster: Google indexes your original content. Spanish-speaking users searching in Spanish won't find your site.

  2. No control: You can't fix bad translations. "Bank" might translate as "bench."

  3. Performance: Every page load hits Google's servers. Adds latency.

  4. UX issues: Page layout breaks when text expands (German is 30% longer than English).

Verdict: Fine for testing. Don't ship this to real users.

Level 2: Manual Static Pages (The Copy-Paste Method)

When to use: Small site (under 10 pages), rarely updated, budget-conscious.

Effort: 1-2 days per language.

How It Works

Create separate HTML files for each language:

/index.html           (English)
/es/index.html        (Spanish)
/fr/index.html        (French)
/de/index.html        (German)

Example Structure

English version (/index.html):

HTML
1<!DOCTYPE html>
2<html lang="en">
3<head>
4  <link rel="alternate" hreflang="es" href="/es/" />
5  <link rel="alternate" hreflang="fr" href="/fr/" />
6  <title>Welcome to Our Product</title>
7</head>
8<body>
9  <nav>
10    <a href="/">English</a>
11    <a href="/es/">Español</a>
12    <a href="/fr/">Français</a>
13  </nav>
14
15  <h1>Welcome to Our Product</h1>
16  <p>Build amazing apps with our platform.</p>
17
18  <button>Get Started</button>
19</body>
20</html>

Spanish version (/es/index.html):

HTML
1<!DOCTYPE html>
2<html lang="es">
3<head>
4  <link rel="alternate" hreflang="en" href="/" />
5  <link rel="alternate" hreflang="fr" href="/fr/" />
6  <title>Bienvenido a Nuestro Producto</title>
7</head>
8<body>
9  <nav>
10    <a href="/">English</a>
11    <a href="/es/">Español</a>
12    <a href="/fr/">Français</a>
13  </nav>
14
15  <h1>Bienvenido a Nuestro Producto</h1>
16  <p>Construye aplicaciones increíbles con nuestra plataforma.</p>
17
18  <button>Comenzar</button>
19</body>
20</html>

SEO Configuration

Add hreflang tags so Google knows which version to show:

HTML
1<!-- In every page -->
2<link rel="alternate" hreflang="en" href="https://example.com/" />
3<link rel="alternate" hreflang="es" href="https://example.com/es/" />
4<link rel="alternate" hreflang="fr" href="https://example.com/fr/" />
5<link rel="alternate" hreflang="x-default" href="https://example.com/" />

Language Detection

Redirect based on browser language:

JavaScript
1// Detect user's language and redirect
2const userLang = navigator.language || navigator.userLanguage;
3const supportedLangs = ['en', 'es', 'fr'];
4const lang = userLang.slice(0, 2);
5
6// Only redirect on homepage
7if (window.location.pathname === '/' && supportedLangs.includes(lang) && lang !== 'en') {
8  // Check if user has manually selected a language before
9  if (!localStorage.getItem('language_selected')) {
10    window.location.href = `/${lang}/`;
11  }
12}
13
14// Remember user's choice
15document.querySelectorAll('nav a').forEach(link => {
16  link.addEventListener('click', () => {
17    localStorage.setItem('language_selected', 'true');
18  });
19});

Pros

  • Complete SEO control
  • Perfect translations (you hire professionals)
  • Fast (no external API calls)
  • Simple hosting (just HTML files)

Cons

  • Maintenance nightmare (update 5 pages for every change)
  • Doesn't scale (100 pages × 5 languages = 500 files)
  • No dynamic content

Verdict: Fine for landing pages, terrible for apps.

Level 3: JSON Files + Library (The Standard Approach)

When to use: Most websites. This is the industry standard.

Frameworks: React, Vue, Next.js, Svelte - all support this.

How It Works

  1. Extract strings to JSON files
  2. Use i18n library to load translations
  3. Replace hardcoded text with translation keys

React Example (react-i18next)

Install:

Terminal
npm install react-i18next i18next

Setup (i18n.js):

JavaScript
1import i18n from 'i18next';
2import { initReactI18next } from 'react-i18next';
3
4import enTranslations from './locales/en.json';
5import esTranslations from './locales/es.json';
6import frTranslations from './locales/fr.json';
7
8i18n
9  .use(initReactI18next)
10  .init({
11    resources: {
12      en: { translation: enTranslations },
13      es: { translation: esTranslations },
14      fr: { translation: frTranslations }
15    },
16    lng: localStorage.getItem('language') || 'en',
17    fallbackLng: 'en',
18    interpolation: {
19      escapeValue: false // React already escapes
20    }
21  });
22
23export default i18n;

Translation files:

locales/en.json:

JSON
1{
2  "welcome": {
3    "title": "Welcome to Our Product",
4    "subtitle": "Build amazing apps with our platform",
5    "cta": "Get Started"
6  },
7  "nav": {
8    "home": "Home",
9    "pricing": "Pricing",
10    "docs": "Documentation"
11  }
12}

locales/es.json:

JSON
1{
2  "welcome": {
3    "title": "Bienvenido a Nuestro Producto",
4    "subtitle": "Construye aplicaciones increíbles con nuestra plataforma",
5    "cta": "Comenzar"
6  },
7  "nav": {
8    "home": "Inicio",
9    "pricing": "Precios",
10    "docs": "Documentación"
11  }
12}

Component:

JSX
1import { useTranslation } from 'react-i18next';
2
3function HomePage() {
4  const { t, i18n } = useTranslation();
5
6  const changeLanguage = (lng) => {
7    i18n.changeLanguage(lng);
8    localStorage.setItem('language', lng);
9  };
10
11  return (
12    <div>
13      <nav>
14        <button onClick={() => changeLanguage('en')}>English</button>
15        <button onClick={() => changeLanguage('es')}>Español</button>
16        <button onClick={() => changeLanguage('fr')}>Français</button>
17      </nav>
18
19      <h1>{t('welcome.title')}</h1>
20      <p>{t('welcome.subtitle')}</p>
21      <button>{t('welcome.cta')}</button>
22    </div>
23  );
24}

With variables:

JSX
1// Translation file
2{
3  "greeting": "Hello, {{name}}! You have {{count}} messages."
4}
5
6// Component
7<p>{t('greeting', { name: 'Sarah', count: 5 })}</p>
8// Output: "Hello, Sarah! You have 5 messages."

With plurals:

JSON
1{
2  "messages": "{{count}} message",
3  "messages_plural": "{{count}} messages"
4}
JSX
<p>{t('messages', { count: 1 })}</p> // "1 message"
<p>{t('messages', { count: 5 })}</p> // "5 messages"

Next.js Example (next-intl)

Install:

Terminal
npm install next-intl

File structure:

/app/[locale]/
├── layout.tsx
├── page.tsx
/messages/
├── en.json
├── es.json
├── fr.json

i18n.ts:

TypeScript
1import { getRequestConfig } from 'next-intl/server';
2
3export default getRequestConfig(async ({ locale }) => ({
4  messages: (await import(`./messages/${locale}.json`)).default
5}));

middleware.ts (locale detection):

TypeScript
1import createMiddleware from 'next-intl/middleware';
2
3export default createMiddleware({
4  locales: ['en', 'es', 'fr'],
5  defaultLocale: 'en'
6});
7
8export const config = {
9  matcher: ['/((?!api|_next|.*\..*).*)']
10};

Page component:

TSX
1import { useTranslations } from 'next-intl';
2
3export default function HomePage() {
4  const t = useTranslations('welcome');
5
6  return (
7    <div>
8      <h1>{t('title')}</h1>
9      <p>{t('subtitle')}</p>
10      <button>{t('cta')}</button>
11    </div>
12  );
13}

URLs:

  • /en → English
  • /es → Spanish
  • /fr → French

Next.js handles routing, SEO, and locale detection automatically.

Vue Example (vue-i18n)

Install:

Terminal
npm install vue-i18n

Setup (main.js):

JavaScript
1import { createApp } from 'vue';
2import { createI18n } from 'vue-i18n';
3import App from './App.vue';
4
5import en from './locales/en.json';
6import es from './locales/es.json';
7
8const i18n = createI18n({
9  locale: localStorage.getItem('language') || 'en',
10  fallbackLocale: 'en',
11  messages: { en, es }
12});
13
14createApp(App).use(i18n).mount('#app');

Component:

VUE
1<template>
2  <div>
3    <select v-model="$i18n.locale" @change="saveLanguage">
4      <option value="en">English</option>
5      <option value="es">Español</option>
6    </select>
7
8    <h1>{{ $t('welcome.title') }}</h1>
9    <p>{{ $t('welcome.subtitle') }}</p>
10  </div>
11</template>
12
13<script>
14export default {
15  methods: {
16    saveLanguage() {
17      localStorage.setItem('language', this.$i18n.locale);
18    }
19  }
20};
21</script>

Managing Translations

Problem: You now have JSON files. How do you:

  • Send them to translators?
  • Track what's translated?
  • Detect missing keys?

Option 1: Manual (painful)

  1. Export JSON to Excel
  2. Email translators
  3. Copy-paste back

Option 2: Use a TMS (smart)

Terminal
1# IntlPull example
2npx @intlpullhq/cli init
3
4# Upload source strings
5npx @intlpullhq/cli upload --source locales/en.json
6
7# Translators translate in web UI
8
9# Download translations
10npx @intlpullhq/cli download
11# Creates locales/es.json, locales/fr.json, etc.

Translators work in a web interface, you just pull updated files.

Level 4: CMS Integration (Marketing Sites)

When to use: Content-heavy sites (blogs, marketing pages).

CMS options: Contentful, Sanity, Strapi.

How It Works

Instead of JSON files, content lives in a CMS. Editors translate there.

Example: Next.js + Contentful

Schema:

JavaScript
1// Contentful content type
2{
3  "name": "Blog Post",
4  "fields": [
5    { "id": "title", "type": "Text", "localized": true },
6    { "id": "body", "type": "RichText", "localized": true },
7    { "id": "slug", "type": "Text", "localized": true }
8  ]
9}

Fetching:

TypeScript
1import { createClient } from 'contentful';
2
3const client = createClient({
4  space: process.env.CONTENTFUL_SPACE_ID,
5  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN
6});
7
8export async function getBlogPost(slug: string, locale: string) {
9  const entries = await client.getEntries({
10    content_type: 'blogPost',
11    'fields.slug': slug,
12    locale: locale // 'en-US', 'es', 'fr'
13  });
14
15  return entries.items[0];
16}

Component:

TSX
1export default async function BlogPost({ params }) {
2  const { slug, locale } = params;
3  const post = await getBlogPost(slug, locale);
4
5  return (
6    <article>
7      <h1>{post.fields.title}</h1>
8      <div>{documentToReactComponents(post.fields.body)}</div>
9    </article>
10  );
11}

Routes:

  • /en/blog/hello-world
  • /es/blog/hola-mundo
  • /fr/blog/bonjour-monde

Pros

  • Non-technical editors can translate
  • Rich text, images, SEO fields all translated
  • Preview before publishing

Cons

  • CMS costs money
  • Vendor lock-in
  • More complex setup

Level 5: Full i18n Platform (Production Scale)

When to use: SaaS products, high-traffic sites, frequent updates.

Platforms: IntlPull, Lokalise, Phrase, Crowdin.

What You Get

  • Web UI for translators (with context, screenshots)
  • CLI for developers (upload/download translations)
  • Git integration (bi-directional sync)
  • Machine translation (DeepL, ChatGPT)
  • Over-the-air updates (change translations without deploying)
  • Translation memory (reuse past translations)
  • Review workflows (approve/reject)

Example: IntlPull Workflow

1. Initial setup:

Terminal
npm install @intlpullhq/react
npx @intlpullhq/cli init

2. Developer adds strings in code:

TSX
1import { useIntlPull } from '@intlpullhq/react';
2
3function App() {
4  const { t } = useIntlPull();
5
6  return (
7    <div>
8      <h1>{t('welcome.title')}</h1>
9      <button>{t('welcome.cta')}</button>
10    </div>
11  );
12}

3. Extract and upload:

Terminal
npx @intlpullhq/cli upload
# Scans code for t() calls
# Uploads new strings to platform

4. Translators translate (in web UI, with screenshots and context)

5. Download translations:

Terminal
npx @intlpullhq/cli download
# Downloads all translations
# Generates locale files

6. Deploy:

Terminal
npm run build

7. OTA updates (optional):

Later, you fix a typo in Spanish. Instead of redeploying:

Terminal
npx @intlpullhq/cli publish --ota

Users get updated translations instantly.

Production Config

Next.js + IntlPull:

TypeScript
1// intlpull.config.ts
2export default {
3  projectId: 'your-project-id',
4  sourceLanguage: 'en',
5  targetLanguages: ['es', 'fr', 'de', 'ja', 'zh'],
6  ota: {
7    enabled: true,
8    pollingInterval: 60000 // Check for updates every minute
9  },
10  translation: {
11    defaultEngine: 'deepl',
12    fallback: 'google'
13  }
14};

Component:

TSX
1'use client';
2
3import { IntlPullProvider, useIntlPull } from '@intlpullhq/react';
4
5export default function RootLayout({ children }) {
6  return (
7    <IntlPullProvider config={{ projectId: 'your-project-id' }}>
8      {children}
9    </IntlPullProvider>
10  );
11}

Advanced: Translation Memory

Reuse past translations automatically:

Terminal
1# String in app 1: "Save changes"
2# Translated to Spanish: "Guardar cambios"
3
4# Later, in app 2, you add "Save changes"
5# IntlPull suggests "Guardar cambios" (100% match)

Saves time, ensures consistency.

SEO for Translated Sites

1. hreflang Tags

Tell Google which language each page is in:

HTML
1<link rel="alternate" hreflang="en" href="https://example.com/en/page" />
2<link rel="alternate" hreflang="es" href="https://example.com/es/page" />
3<link rel="alternate" hreflang="fr" href="https://example.com/fr/page" />
4<link rel="alternate" hreflang="x-default" href="https://example.com/en/page" />

2. Localized URLs

Option A: Subdirectories (recommended)

  • example.com/en/
  • example.com/es/
  • example.com/fr/

Option B: Subdomains

  • en.example.com
  • es.example.com
  • fr.example.com

Option C: Different domains

  • example.com (English)
  • example.es (Spanish)
  • example.fr (French)

Subdirectories are easiest for SEO (all authority in one domain).

3. Localized Metadata

TSX
1// Next.js example
2export async function generateMetadata({ params }) {
3  const { locale } = params;
4  const titles = {
5    en: 'Best Translation Platform for Developers',
6    es: 'Mejor Plataforma de Traducción para Desarrolladores',
7    fr: 'Meilleure Plateforme de Traduction pour Développeurs'
8  };
9
10  return {
11    title: titles[locale],
12    description: t('meta.description'), // From translation file
13    openGraph: {
14      title: titles[locale],
15      locale: locale
16    }
17  };
18}

Performance Optimization

1. Code Splitting

Only load the current language:

JavaScript
1// ❌ Bad: Load all languages
2import en from './locales/en.json';
3import es from './locales/es.json';
4import fr from './locales/fr.json';
5
6// ✅ Good: Lazy load
7const loadTranslations = async (locale) => {
8  return await import(`./locales/${locale}.json`);
9};

2. Caching

TypeScript
1// Cache translations in localStorage
2const cacheTranslations = (locale, data) => {
3  const cache = {
4    locale,
5    data,
6    timestamp: Date.now()
7  };
8  localStorage.setItem('translations', JSON.stringify(cache));
9};
10
11const getCachedTranslations = (locale) => {
12  const cached = localStorage.getItem('translations');
13  if (!cached) return null;
14
15  const { locale: cachedLocale, data, timestamp } = JSON.parse(cached);
16
17  // Invalidate after 24 hours
18  if (cachedLocale !== locale || Date.now() - timestamp > 86400000) {
19    return null;
20  }
21
22  return data;
23};

3. Bundle Size

Translation files can get huge. Compress them:

Terminal
1# Before
2locales/en.json: 450 KB
3
4# After gzip
5locales/en.json.gz: 85 KB

Most servers gzip automatically, but verify.

Common Pitfalls

1. Hardcoded Strings Everywhere

TSX
1// ❌ Found this in production code
2<button>Submit</button>
3<p>Welcome to our app</p>
4<div>Loading...</div>

Translators can't fix this. Use a linter:

Terminal
npm install eslint-plugin-i18next

.eslintrc:

JSON
1{
2  "plugins": ["i18next"],
3  "rules": {
4    "i18next/no-literal-string": "error"
5  }
6}

Now ESLint yells at you: "Don't hardcode 'Submit'!"

2. Forgetting Images/Icons

Text changes, but icons might not translate culturally:

  • Thumbs up 👍 is offensive in some Middle Eastern countries
  • OK gesture 👌 is rude in Brazil
  • Colors have meanings (white = mourning in China)

Use different image sets per locale when needed.

3. Text Expansion

German is ~30% longer than English. Your UI will break.

Test with pseudo-localization:

JavaScript
// Expands English to simulate German
"Hello""Ĥéļļö [ẋẋẋẋ]"
"Submit""Śûƀɱîţ [ẋẋẋẋẋ]"

If UI breaks with pseudo-localization, it'll break with German.

4. Date/Number Formatting

Don't do this:

JavaScript
`${month}/${day}/${year}` // US-only format

Do this:

JavaScript
new Intl.DateTimeFormat(locale).format(date)

Same for numbers, currencies, etc. Use Intl API.

Cost Breakdown

DIY (Level 3 - JSON files):

  • Development: 3-5 days × $500/day = $1,500-2,500
  • Translation: $0.10/word × 5,000 words × 3 languages = $1,500
  • Maintenance: 2 hours/week × $100/hour = $200/week
  • Total Year 1: ~$14,000

TMS Platform (Level 5 - IntlPull):

  • Development: 1-2 days × $500/day = $500-1,000
  • Platform: $29/month × 12 = $348
  • Translation (with MT + review): $0.03/word × 5,000 × 3 = $450
  • Maintenance: 0.5 hours/week × $100/hour = $50/week
  • Total Year 1: ~$4,400

Platform saves $10,000 in the first year.

The Decision Matrix

SituationRecommended Approach
Testing market demandGoogle Translate widget (Level 1)
5-page marketing siteManual static pages (Level 2)
React/Vue app, 2-3 languagesJSON files + react-i18next (Level 3)
Next.js, 5+ languagesnext-intl + TMS (Level 3 + 5)
SaaS product, frequent updatesFull i18n platform (Level 5)
Blog/content siteCMS integration (Level 4)

Quick Start: React App in 30 Minutes

Let's translate a real app from scratch.

Step 1: Install

Terminal
npm install react-i18next i18next

Step 2: Create files

src/i18n/index.js:

JavaScript
1import i18n from 'i18next';
2import { initReactI18next } from 'react-i18next';
3import en from './locales/en.json';
4import es from './locales/es.json';
5
6i18n.use(initReactI18next).init({
7  resources: { en: { translation: en }, es: { translation: es } },
8  lng: 'en',
9  fallbackLng: 'en'
10});
11
12export default i18n;

src/i18n/locales/en.json:

JSON
1{
2  "nav": { "home": "Home", "about": "About" },
3  "home": {
4    "title": "Welcome",
5    "cta": "Get Started"
6  }
7}

src/i18n/locales/es.json:

JSON
1{
2  "nav": { "home": "Inicio", "about": "Acerca de" },
3  "home": {
4    "title": "Bienvenido",
5    "cta": "Comenzar"
6  }
7}

Step 3: Use in components

src/App.js:

JSX
1import './i18n';
2import { useTranslation } from 'react-i18next';
3
4function App() {
5  const { t, i18n } = useTranslation();
6
7  return (
8    <div>
9      <nav>
10        <button onClick={() => i18n.changeLanguage('en')}>EN</button>
11        <button onClick={() => i18n.changeLanguage('es')}>ES</button>
12      </nav>
13      <h1>{t('home.title')}</h1>
14      <button>{t('home.cta')}</button>
15    </div>
16  );
17}

Done. Your app is translated.


Want to skip the manual workflow?

Try IntlPull free. Push source strings from code, translators work in a web UI, pull translations before deploy. OTA updates included.

Or DIY it if you're comfortable managing JSON files and translator workflows yourself.

Tags
web-translation
i18n
react
nextjs
vue
website-localization
tutorial
IntlPull Team
IntlPull Team
Engineering

Building tools to help teams ship products globally. Follow us for more insights on localization and i18n.