Back to Blog

How I translate this blog with AI

2025-08-2110 min read

I am a native Spanish speaker. I live in the United States, so I use English every day. I write my posts in English to practice and to reach more people.

Google Analytics showed me something important. Around 30-40% of my visitors are from countries where English is not the main language. That told me I was leaving value on the table. So I decided to translate the blog with AI.

My setup

  • How I store posts: Each blog post is a simple file with the title, date, content, and other details. Think of it like a digital recipe card.
  • My website framework: I use Next.js with internationalization tools that help handle multiple languages automatically.
  • The AI I use: Google's Gemini AI does the actual translation work. I give it strict instructions to only translate the text content and leave all the technical parts (like links and code) untouched.
  • One command does everything: I run a single script that sends my English post to Google's Gemini AI, which translates it to all 8 languages simultaneously. The script then organizes these translations into separate files, updates all the cross-language links so readers can switch between languages seamlessly, and double-checks that every language version exists and is complete.
  • Language switching: When readers click to change languages, they get taken to the right version of the post automatically.

Important note: The script is called translate-gemini.ts . It's a generic translation script that can translate to any language. It uses the TARGET_LOCALE environment variable to determine which language to translate to. So you only need this one script, not separate scripts for each language.

My workflow

  1. I write the post in English as app/[locale]/blog/posts/
    <slug>/index.json
    . The content field is HTML. Code blocks are fenced.
  2. I translate with one command. The model translates title , excerpt , content , and readTime . Meta like date , tags , and categories stay the same.
  3. I keep slugs in sync with a translatedSlugs map in every file, English and translations.
  4. I validate that each language exists and the slug map is complete.

Why AI works for me

  • Follows instructions: The prompt protects HTML, code, and links.
  • Cultural adaptation: AI adapts cultural references and examples to be relevant for each language audience, which I can't do manually since I don't know all the cultural nuances.
  • Fast and good: A fast model with an automatic fallback.
  • Consistent tone: Low temperature keeps the style steady across languages.

Safety rails

  • HTML safe translation: I only send the values of JSON keys, not tags or attributes.
  • Slug discipline: translatedSlugs holds the slug for en , es , fr , de , ru , nl , it , and zh .
  • Validation: A command checks that each language file exists and that the slug map is complete.
  • Polite to the API: I add a small delay between calls.

Translations and SEO

Many people think multilingual SEO is just about translating content. That's only the beginning. Here's what really matters:

The technical foundation

  • Hreflang tags: These tell search engines which language page to show to which users. Without them, Google might show the wrong language or treat your translations as duplicate content.
  • URL structure: Each language needs its own URL path. I use /es/blog/ , /fr/blog/ , etc. This helps search engines understand the language hierarchy.
  • Sitemaps: Include all language versions in your sitemap with proper language annotations. Search engines need to discover and index each version.
  • Canonical tags: Point each language version to itself to avoid confusion about which is the "main" version.

Beyond translation: Cultural adaptation

  • Keywords in each language: People search differently in different languages. "How to learn programming" becomes "cómo aprender programación" in Spanish, not just a literal translation.
  • Cultural context: Some concepts don't translate directly. I rely on AI to adapt examples and references to be relevant to each audience, since I don't know all the cultural nuances of every language.
  • Search volume varies: Some topics are more popular in certain languages. I prioritize languages where my content has the most potential reach.

User experience signals

  • Language detection: Automatically detect user language from browser settings, but let them override it.
  • Consistent navigation: The language switcher must work on every page and maintain the user's context.
  • Loading speed: Each language version should load as fast as the original. Don't let translation slow down your site.
  • Mobile experience: Language switching needs to work well on mobile devices where screen space is limited.

Content strategy considerations

  • Not everything needs translation: Some posts might be more relevant to English speakers. I translate selectively based on potential audience.
  • Update synchronization: When I update the English version, all translations need to be updated too. Stale translations hurt SEO.
  • Quality over quantity: Better to have fewer, high-quality translations than many poorly translated pages.
  • Local backlinks: Translated content can earn backlinks from sites in those languages, improving your overall domain authority.

The key insight: Multilingual SEO is about creating separate, optimized experiences for each language audience, not just translating words.

The commands I run

# Translate one post to all languages\nnpm run translate-all translate my-new-post\n\n# Validate that all language files and slug maps exist\nnpm run translate-all validate my-new-post\n\n# Re run and overwrite translations after editing English\nnpm run translate-all translate my-new-post --force

That is it. One to translate, one to validate. The switcher works because each JSON file lists the per language slug.

The complete translation system

My translation system consists of two scripts working together. The main orchestrator script handles multiple languages, while the worker script does the actual AI translation. Here are both scripts:

Main orchestrator script (translate-all-languages.ts)

This is the script you run directly. It coordinates translation to all 8 languages and manages slug mappings:

Click to expand/collapse the orchestrator script
import fs from 'fs-extra';\nimport path from 'path';\nimport { execSync } from 'child_process';\n\ntype PostData = Record;\n\nconst SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de', 'ru', 'nl', 'it', 'zh'] as const;\nconst SOURCE_LOCALE = 'en';\n\ninterface TranslationConfig {\n  locale: string;\n  scriptCommand: string;\n}\n\nconst TRANSLATION_CONFIGS: TranslationConfig[] = [\n  { locale: 'es', scriptCommand: 'npm run translate-es-gemini' },\n  { locale: 'fr', scriptCommand: 'npm run translate-fr-gemini' },\n  { locale: 'de', scriptCommand: 'npm run translate-de-gemini' },\n  { locale: 'ru', scriptCommand: 'npm run translate-ru-gemini' },\n  { locale: 'nl', scriptCommand: 'npm run translate-nl-gemini' },\n  { locale: 'it', scriptCommand: 'npm run translate-it-gemini' },\n  { locale: 'zh', scriptCommand: 'npm run translate-zh-gemini' },\n];\n\n/**\n * Generate slug mappings from translated file names\n */\nasync function generateSlugMappings(postSlug: string): Promise> {\n  const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts', postSlug);\n  const slugMappings: Record = {};\n\n  if (!(await fs.pathExists(postsDir))) {\n    throw new Error(\`Post directory not found: ${postsDir}\`);\n  }\n\n  // Start with English slug\n  slugMappings[SOURCE_LOCALE] = postSlug;\n\n  // Check for existing translation files to extract slugs\n  for (const locale of SUPPORTED_LOCALES) {\n    if (locale === SOURCE_LOCALE) continue;\n\n    const translationFile = path.join(postsDir, \`index.${locale}.json\`);\n    if (await fs.pathExists(translationFile)) {\n      try {\n        const translationData: PostData = await fs.readJson(translationFile);\n        const translatedSlugs = translationData.translatedSlugs;\n        if (translatedSlugs && translatedSlugs[locale]) {\n          slugMappings[locale] = translatedSlugs[locale];\n        } else {\n          console.warn(\`Warning: Missing translated slug for ${locale} in ${postSlug}\`);\n          slugMappings[locale] = postSlug; // Fallback to English slug\n        }\n      } catch (error) {\n        console.warn(\`Warning: Could not read translation file for ${locale}: ${translationFile}\`);\n        slugMappings[locale] = postSlug; // Fallback to English slug\n      }\n    } else {\n      console.warn(\`Warning: Translation file missing for ${locale}: ${translationFile}\`);\n      slugMappings[locale] = postSlug; // Fallback to English slug\n    }\n  }\n\n  return slugMappings;\n}\n\n/**\n * Update all translation files with complete slug mappings\n */\nasync function updateSlugMappings(postSlug: string, slugMappings: Record) {\n  const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts', postSlug);\n\n  for (const locale of SUPPORTED_LOCALES) {\n    const fileName = locale === SOURCE_LOCALE ? 'index.json' : \`index.${locale}.json\`;\n    const filePath = path.join(postsDir, fileName);\n\n    if (await fs.pathExists(filePath)) {\n      try {\n        const data: PostData = await fs.readJson(filePath);\n        data.translatedSlugs = slugMappings;\n        await fs.writeJson(filePath, data, { spaces: 2 });\n        console.log(\`✅ Updated slug mappings in ${fileName}\`);\n      } catch (error) {\n        console.error(\`❌ Failed to update slug mappings in ${fileName}:\`, error);\n      }\n    }\n  }\n}\n\n/**\n * Translate a single post to all languages\n */\nasync function translatePost(postSlug: string, force: boolean = false) {\n  const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts', postSlug);\n  const sourceFile = path.join(postsDir, 'index.json');\n\n  // Verify source file exists\n  if (!(await fs.pathExists(sourceFile))) {\n    throw new Error(\`Source file not found: ${sourceFile}\`);\n  }\n\n  console.log(\`\\n🌍 Translating post: ${postSlug}\`);\n  console.log(\`📁 Directory: ${postsDir}\`);\n\n  // Translate to each target language\n  for (const config of TRANSLATION_CONFIGS) {\n    const targetFile = path.join(postsDir, \`index.${config.locale}.json\`);\n    const exists = await fs.pathExists(targetFile);\n\n    if (!exists || force) {\n      try {\n        console.log(\`\\n🔄 Translating to ${config.locale}...\`);\n        const command = \`${config.scriptCommand} -- --only ${postSlug} ${force ? '--force' : ''}\`;\n        execSync(command, { stdio: 'inherit', cwd: process.cwd() });\n        console.log(\`✅ Successfully translated to ${config.locale}\`);\n        \n        // Small delay to be polite to the API\n        await new Promise(resolve => setTimeout(resolve, 500));\n      } catch (error) {\n        console.error(\`❌ Failed to translate to ${config.locale}:\`, error);\n      }\n    } else {\n      console.log(\`⏭️  Skipping ${config.locale} (already exists, use --force to overwrite)\`);\n    }\n  }\n\n  // Generate and update slug mappings\n  console.log(\`\\n🔗 Updating slug mappings...\`);\n  try {\n    const slugMappings = await generateSlugMappings(postSlug);\n    await updateSlugMappings(postSlug, slugMappings);\n    console.log(\`✅ Slug mappings updated for all languages\`);\n  } catch (error) {\n    console.error(\`❌ Failed to update slug mappings:\`, error);\n  }\n}\n\n/**\n * Find all blog posts\n */\nasync function findAllPosts(): Promise {\n  const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts');\n  \n  if (!(await fs.pathExists(postsDir))) {\n    throw new Error(\`Posts directory not found: ${postsDir}\`);\n  }\n\n  const entries = await fs.readdir(postsDir, { withFileTypes: true });\n  return entries\n    .filter(entry => entry.isDirectory())\n    .map(entry => entry.name)\n    .filter(name => !name.startsWith('.'));\n}\n\n/**\n * Validate translation completeness\n */\nasync function validateTranslations(postSlug?: string) {\n  const posts = postSlug ? [postSlug] : await findAllPosts();\n  let allValid = true;\n\n  console.log(\`\\n🔍 Validating translations for ${posts.length} post(s)...\`);\n\n  for (const slug of posts) {\n    const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts', slug);\n    console.log(\`\\n📋 Checking: ${slug}\`);\n\n    for (const locale of SUPPORTED_LOCALES) {\n      const fileName = locale === SOURCE_LOCALE ? 'index.json' : \`index.${locale}.json\`;\n      const filePath = path.join(postsDir, fileName);\n      \n      if (await fs.pathExists(filePath)) {\n        try {\n          const data: PostData = await fs.readJson(filePath);\n          \n          // Check if translatedSlugs is complete\n          const translatedSlugs = data.translatedSlugs || {};\n          const missingLanguages = SUPPORTED_LOCALES.filter(lang => !translatedSlugs[lang]);\n          \n          if (missingLanguages.length > 0) {\n            console.log(\`  ⚠️  ${locale}: Missing slug mappings for ${missingLanguages.join(', ')}\`);\n            allValid = false;\n          } else {\n            console.log(\`  ✅ ${locale}: Complete\`);\n          }\n        } catch (error) {\n          console.log(\`  ❌ ${locale}: Invalid JSON\`);\n          allValid = false;\n        }\n      } else {\n        console.log(\`  ❌ ${locale}: Missing file\`);\n        allValid = false;\n      }\n    }\n  }\n\n  return allValid;\n}\n\nasync function main() {\n  const args = process.argv.slice(2);\n  const command = args[0];\n  \n  try {\n    switch (command) {\n      case 'translate': {\n        const postSlug = args.find(arg => !arg.startsWith('--') && arg !== 'translate');\n        const force = args.includes('--force');\n        const all = args.includes('--all');\n\n        if (all) {\n          const allPosts = await findAllPosts();\n          console.log(\`🚀 Translating all ${allPosts.length} posts...\`);\n          \n          for (const slug of allPosts) {\n            await translatePost(slug, force);\n          }\n        } else if (postSlug) {\n          await translatePost(postSlug, force);\n        } else {\n          console.error('❌ Please specify a post slug or use --all');\n          process.exit(1);\n        }\n        break;\n      }\n\n      case 'validate': {\n        const postSlug = args.find(arg => !arg.startsWith('--') && arg !== 'validate');\n        const isValid = await validateTranslations(postSlug);\n        \n        if (isValid) {\n          console.log('\\n✅ All translations are complete and valid!');\n        } else {\n          console.log('\\n⚠️  Some translations are incomplete or invalid.');\n          process.exit(1);\n        }\n        break;\n      }\n\n      case 'update-slugs': {\n        const postSlug = args.find(arg => !arg.startsWith('--') && arg !== 'update-slugs');\n        \n        if (postSlug) {\n          const slugMappings = await generateSlugMappings(postSlug);\n          await updateSlugMappings(postSlug, slugMappings);\n          console.log(\`✅ Updated slug mappings for ${postSlug}\`);\n        } else {\n          console.error('❌ Please specify a post slug');\n          process.exit(1);\n        }\n        break;\n      }\n\n      default:\n        console.log(\`\n🌍 Blog Translation Manager\n\nUsage:\n  npm run translate-all translate  [--force]  # Translate specific post\n  npm run translate-all translate --all [--force]       # Translate all posts\n  npm run translate-all validate [post-slug]            # Validate translations\n  npm run translate-all update-slugs         # Update slug mappings\n\nExamples:\n  npm run translate-all translate how-to-read-nginx-access-logs\n  npm run translate-all translate --all --force\n  npm run translate-all validate\n  npm run translate-all update-slugs how-to-read-nginx-access-logs\n        \`);\n        break;\n    }\n  } catch (error) {\n    console.error('❌ Error:', error);\n    process.exit(1);\n  }\n}\n\nmain().catch(console.error);

How the script works

The script does several things:

  • Translation orchestration: It calls individual language translation scripts for each target language
  • Slug management: It generates and updates the translatedSlugs mapping in every file
  • Validation: It checks that all language files exist and have complete slug mappings
  • Error handling: It gracefully handles failures and continues with other languages

The key insight is that each language file needs to know the slug for every other language. This lets the language switcher work correctly.

AI API configuration

Here's how I configure the Gemini API calls for reliable translation:

Token limits and parameters

  • maxOutputTokens: 8192: This is crucial for large content. The default limit is much lower, which caused truncated responses and parsing failures. With 8192 tokens, I can translate up to ~6,000 words (English), which covers most blog posts and articles.
  • temperature: 0.2: Low temperature keeps translations consistent and predictable across languages.
  • topP: 0.95: Controls diversity while maintaining quality.
  • topK: 40: Limits vocabulary choices for more focused translations.

Token-to-word conversion

  • English input: ~0.75 words per token, so 8192 tokens ≈ 6,000-6,500 words
  • Translation expansion: Output is usually 10-30% longer than input (German can be 20-30% longer, Spanish 10-20% longer)
  • HTML markup: Takes up tokens but doesn't translate, so actual translatable content is less
  • Real-world example: This blog post (~2,400 words) uses ~3,000 input tokens and ~4,000-5,000 output tokens

Content handling strategy

  • Small fields first: Translate title, excerpt, and readTime in one API call (fast and reliable).
  • Full content translation: For content over 2,000 characters, translate the entire content in one call with high token limits.
  • Chunking fallback: If full translation fails, automatically fall back to breaking content into ~1,500 character chunks.
  • Smart JSON parsing: Handle responses wrapped in markdown code blocks (```json) that the AI sometimes returns.

Error handling

  • Model fallback: If the primary model (gemini-2.5-flash-lite) isn't available, automatically switch to gemini-1.5-flash-latest.
  • Graceful degradation: If a chunk fails, use the original English content for that section and continue.
  • API politeness: Add delays between calls to respect rate limits.

The key lesson: Always set maxOutputTokens explicitly. The default limits are too low for real-world content, causing mysterious parsing failures that look like API issues but are actually token limit problems.

Tips if you try this

  • Write simply. Short sentences translate well.
  • Fence code blocks and wrap inline code in <code> .
  • Avoid wordplay if you will not review the target text.
  • Use semantic HTML. Headings and lists help readers and models.
  • Keep your source content clean and well-structured.

FAQ

Do I edit translations?

Only for Spanish, since that's the only language I know besides English. For titles and intros, I want a fast starting point, then I polish where it matters. For other languages, I rely on the AI translation quality.

Privacy?

Content goes to the Gemini API. I do not send secrets. API keys live in .env.local ( GOOGLE_API_KEY or GEMINI_API_KEY ).

Models?

I use a fast Gemini model and fall back automatically if it is unavailable. You can override with GEMINI_MODEL .

What about the old URLs?

I keep the old URLs working with redirects. The slug mapping system handles this automatically.

Takeaway

AI does not replace your voice. It removes the busywork. Keep your source clean, automate the repeatable parts, validate, and ship. The script I shared handles the complexity so you can focus on writing.