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:
| Level | Effort | Quality | Use Case |
|---|---|---|---|
| 1. Google Translate Widget | 5 minutes | Poor | Temporary, testing demand |
| 2. Manual Static Pages | 1-2 days | Good | Small sites (5-10 pages) |
| 3. JSON Files + Library | 3-5 days | Great | Most websites |
| 4. CMS Integration | 1-2 weeks | Great | Marketing sites |
| 5. Full i18n Platform | 2-4 weeks | Excellent | SaaS, 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>:
HTML1<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:
CSS1/* 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
-
SEO disaster: Google indexes your original content. Spanish-speaking users searching in Spanish won't find your site.
-
No control: You can't fix bad translations. "Bank" might translate as "bench."
-
Performance: Every page load hits Google's servers. Adds latency.
-
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):
HTML1<!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):
HTML1<!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:
HTML1<!-- 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:
JavaScript1// 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
- Extract strings to JSON files
- Use i18n library to load translations
- Replace hardcoded text with translation keys
React Example (react-i18next)
Install:
Terminalnpm install react-i18next i18next
Setup (i18n.js):
JavaScript1import 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:
JSON1{ 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:
JSON1{ 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:
JSX1import { 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:
JSX1// 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:
JSON1{ 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:
Terminalnpm install next-intl
File structure:
/app/[locale]/
├── layout.tsx
├── page.tsx
/messages/
├── en.json
├── es.json
├── fr.json
i18n.ts:
TypeScript1import { getRequestConfig } from 'next-intl/server'; 2 3export default getRequestConfig(async ({ locale }) => ({ 4 messages: (await import(`./messages/${locale}.json`)).default 5}));
middleware.ts (locale detection):
TypeScript1import 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:
TSX1import { 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:
Terminalnpm install vue-i18n
Setup (main.js):
JavaScript1import { 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:
VUE1<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)
- Export JSON to Excel
- Email translators
- Copy-paste back
Option 2: Use a TMS (smart)
Terminal1# 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:
JavaScript1// 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:
TypeScript1import { 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:
TSX1export 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/downloadtranslations) - 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:
Terminalnpm install @intlpullhq/react npx @intlpullhq/cli init
2. Developer adds strings in code:
TSX1import { 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:
Terminalnpx @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:
Terminalnpx @intlpullhq/cli download # Downloads all translations # Generates locale files
6. Deploy:
Terminalnpm run build
7. OTA updates (optional):
Later, you fix a typo in Spanish. Instead of redeploying:
Terminalnpx @intlpullhq/cli publish --ota
Users get updated translations instantly.
Production Config
Next.js + IntlPull:
TypeScript1// 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:
TSX1'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:
Terminal1# 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:
HTML1<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.comes.example.comfr.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
TSX1// 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:
JavaScript1// ❌ 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
TypeScript1// 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:
Terminal1# 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
TSX1// ❌ 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:
Terminalnpm install eslint-plugin-i18next
.eslintrc:
JSON1{ 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:
JavaScriptnew 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
| Situation | Recommended Approach |
|---|---|
| Testing market demand | Google Translate widget (Level 1) |
| 5-page marketing site | Manual static pages (Level 2) |
| React/Vue app, 2-3 languages | JSON files + react-i18next (Level 3) |
| Next.js, 5+ languages | next-intl + TMS (Level 3 + 5) |
| SaaS product, frequent updates | Full i18n platform (Level 5) |
| Blog/content site | CMS integration (Level 4) |
Quick Start: React App in 30 Minutes
Let's translate a real app from scratch.
Step 1: Install
Terminalnpm install react-i18next i18next
Step 2: Create files
src/i18n/index.js:
JavaScript1import 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:
JSON1{ 2 "nav": { "home": "Home", "about": "About" }, 3 "home": { 4 "title": "Welcome", 5 "cta": "Get Started" 6 } 7}
src/i18n/locales/es.json:
JSON1{ 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:
JSX1import './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.
