Hoe ik deze blog vertaal met AI
Ik ben moedertaalspreker Spaans. Ik woon in de Verenigde Staten, dus ik gebruik elke dag Engels. Ik schrijf mijn berichten in het Engels om te oefenen en om meer mensen te bereiken.
Google Analytics toonde me iets belangrijks. Ongeveer 30-40% van mijn bezoekers komt uit landen waar Engels niet de belangrijkste taal is. Dat betekende dat ik waarde liet liggen. Dus besloot ik de blog met AI te vertalen.
Mijn setup
- Hoe ik berichten opsla: Elk blogbericht is een eenvoudig bestand met de titel, datum, inhoud en andere details. Denk aan een digitaal receptenkaartje.
- Mijn websiteframework: Ik gebruik Next.js met internationalisatietools die helpen om meerdere talen automatisch te verwerken.
- De AI die ik gebruik: Google's Gemini AI doet het eigenlijke vertaalwerk. Ik geef het strikte instructies om alleen de tekstinhoud te vertalen en alle technische onderdelen (zoals links en code) onaangeroerd te laten.
- Eén commando doet alles: Ik voer een enkel script uit dat mijn Engelse bericht naar Google's Gemini AI stuurt, dat het gelijktijdig naar alle 8 talen vertaalt. Het script organiseert deze vertalingen vervolgens in aparte bestanden, werkt alle cross-language links bij zodat lezers naadloos tussen talen kunnen schakelen, en controleert dubbel of elke taalversie bestaat en compleet is.
- Taalwisseling: Wanneer lezers klikken om van taal te wisselen, worden ze automatisch naar de juiste versie van het bericht gebracht.
Belangrijke opmerking: Het script heet
translate-gemini.ts
. Het is een generiek vertaalscript dat naar elke taal kan vertalen. Het gebruikt de
TARGET_LOCALE
omgevingsvariabele om te bepalen naar welke taal moet worden vertaald. Je hebt dus maar dit ene script nodig, geen aparte scripts voor elke taal.
Mijn workflow
- Ik schrijf het bericht in het Engels als
app/[locale]/blog/posts/
. Het
<slug>/index.jsoncontent
veld is HTML. Codeblokken zijn afgezet. - Ik vertaal met één commando. Het model vertaalt
title
,excerpt
,content
, enreadTime
. Metadata zoalsdate
,tags
, encategories
blijven hetzelfde. - Ik houd slugs gesynchroniseerd met een
translatedSlugs
map in elk bestand, Engels en vertalingen. - Ik valideer dat elke taal bestaat en dat de slug map compleet is.
Waarom AI voor mij werkt
- Volgt instructies: De prompt beschermt HTML, code en links.
- Culturele aanpassing: AI past culturele referenties en voorbeelden aan zodat ze relevant zijn voor elk taalpubliek, wat ik niet handmatig kan doen omdat ik niet alle culturele nuances ken.
- Snel en goed: Een snel model met een automatische fallback.
- Consistente toon: Lage temperatuur houdt de stijl consistent over talen heen.
Veiligheidsvoorzieningen
- HTML-veilige vertaling: Ik stuur alleen de waarden van JSON-sleutels, geen tags of attributen.
- Slug discipline:
translatedSlugs
bevat de slug vooren
,es
,fr
,de
,ru
,nl
,it
, enzh
. - Validatie: Een commando controleert of elk taalbestand bestaat en of de slug map compleet is.
- Beleefd tegen de API: Ik voeg een kleine vertraging toe tussen de aanroepen.
Vertalingen en SEO
Veel mensen denken dat meertalige SEO alleen maar gaat over het vertalen van content. Dat is slechts het begin. Dit is wat echt belangrijk is:
De technische basis
- Hreflang tags: Deze vertellen zoekmachines welke taalpagina aan welke gebruikers moet worden getoond. Zonder deze tags kan Google de verkeerde taal tonen of uw vertalingen behandelen als dubbele content.
- URL-structuur: Elke taal heeft zijn eigen URL-pad nodig. Ik gebruik
/es/blog/
,/fr/blog/
, enz. Dit helpt zoekmachines de taalhiërarchie te begrijpen. - Sitemaps: Neem alle taalversies op in uw sitemap met de juiste taalaantekeningen. Zoekmachines moeten elke versie kunnen ontdekken en indexeren.
- Canonical tags: Wijs elke taalversie naar zichzelf om verwarring te voorkomen over welke de "hoofd" versie is.
Meer dan vertaling: Culturele aanpassing
- Trefwoorden in elke taal: Mensen zoeken anders in verschillende talen. "How to learn programming" wordt "cómo aprender programación" in het Spaans, niet zomaar een letterlijke vertaling.
- Culturele context: Sommige concepten laten zich niet direct vertalen. Ik vertrouw op AI om voorbeelden en referenties aan te passen zodat ze relevant zijn voor elk publiek, aangezien ik niet alle culturele nuances van elke taal ken.
- Zoekvolume varieert: Sommige onderwerpen zijn populairder in bepaalde talen. Ik geef prioriteit aan talen waar mijn content het meeste potentiële bereik heeft.
Gebruikerservaringssignalen
- Taaldetectie: Detecteer automatisch de gebruikers taal aan de hand van de browserinstellingen, maar laat ze deze overschrijven.
- Consistente navigatie: De taalwisselaar moet op elke pagina werken en de context van de gebruiker behouden.
- Laadsnelheid: Elke taalversie moet net zo snel laden als het origineel. Laat vertaling uw site niet vertragen.
- Mobiele ervaring: Taalwisseling moet goed werken op mobiele apparaten waar de schermruimte beperkt is.
Overwegingen voor contentstrategie
- Niet alles hoeft vertaald te worden: Sommige berichten zijn mogelijk relevanter voor Engelssprekenden. Ik vertaal selectief op basis van het potentiële publiek.
- Updatesynchronisatie: Wanneer ik de Engelse versie update, moeten alle vertalingen ook worden geüpdatet. Verouderde vertalingen schaden de SEO.
- Kwaliteit boven kwantiteit: Het is beter om minder, hoogwaardige vertalingen te hebben dan veel slecht vertaalde pagina's.
- Lokale backlinks: Vertaalde content kan backlinks verdienen van sites in die talen, waardoor uw algehele domeinautoriteit verbetert.
De belangrijkste conclusie: Meertalige SEO gaat over het creëren van aparte, geoptimaliseerde ervaringen voor elk taalpubliek, niet alleen het vertalen van woorden.
De commando's die ik uitvoer
# Vertaal één bericht naar alle talen\nnpm run translate-all translate my-new-post\n\n# Valideer dat alle taalbestanden en slug maps bestaan\nnpm run translate-all validate my-new-post\n\n# Voer opnieuw uit en overschrijf vertalingen na het bewerken van Engels\nnpm run translate-all translate my-new-post --force
Dat is het. Eén om te vertalen, één om te valideren. De wisselaar werkt omdat elk JSON-bestand de slug per taal vermeldt.
Het complete vertaalsysteem
Mijn vertaalsysteem bestaat uit twee scripts die samenwerken. Het belangrijkste orchestrator-script behandelt meerdere talen, terwijl het worker-script de eigenlijke AI-vertaling uitvoert. Hier zijn beide scripts:
Hoofdorchestrator-script (translate-all-languages.ts)
Dit is het script dat je direct uitvoert. Het coördineert de vertaling naar alle 8 talen en beheert slug-mappings:
Klik om het orchestrator-script uit te vouwen/samen te vouwen
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] # Vertaal specifiek bericht\n npm run translate-all translate --all [--force] # Vertaal alle berichten\n npm run translate-all validate [post-slug] # Valideer vertalingen\n npm run translate-all update-slugs # Update slug mappings\n\nVoorbeelden:\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);
Hoe het script werkt
Het script doet verschillende dingen:
- Vertaling orchestration: Het roept individuele taalvertaalscripts aan voor elke doeltaal
- Slug management: Het genereert en update de
translatedSlugs
mapping in elk bestand - Validatie: Het controleert of alle taalbestanden bestaan en complete slug mappings hebben
- Foutmelding: Het behandelt fouten op een elegante manier en gaat verder met andere talen
De belangrijkste conclusie is dat elk taalbestand de slug voor elke andere taal moet kennen. Dit zorgt ervoor dat de taalwisselaar correct werkt.
AI API configuratie
Zo configureer ik de Gemini API-aanroepen voor betrouwbare vertaling:
Tokenlimieten en parameters
- maxOutputTokens: 8192: Dit is cruciaal voor grote content. De standaardlimiet is veel lager, wat leidde tot afgekorte reacties en parsingfouten. Met 8192 tokens kan ik tot ~6.000 woorden (Engels) vertalen, wat de meeste blogberichten en artikelen dekt.
- temperature: 0.2: Lage temperatuur houdt vertalingen consistent en voorspelbaar over talen heen.
- topP: 0.95: Beheerst diversiteit terwijl de kwaliteit behouden blijft.
- topK: 40: Beperkt vocabulairekeuzes voor meer gerichte vertalingen.
Token-naar-woord conversie
- Engelse invoer: ~0,75 woorden per token, dus 8192 tokens ≈ 6.000-6.500 woorden
- Vertalingsexpansie: Output is meestal 10-30% langer dan input (Duits kan 20-30% langer zijn, Spaans 10-20% langer)
- HTML-markup: Neemt tokens in beslag maar vertaalt niet, dus de werkelijke vertaalbare content is minder
- Echt voorbeeld: Dit blogbericht (~2.400 woorden) gebruikt ~3.000 input tokens en ~4.000-5.000 output tokens
Content handling strategie
- Kleine velden eerst: Vertaal titel, samenvatting en readTime in één API-aanroep (snel en betrouwbaar).
- Volledige contentvertaling: Voor content van meer dan 2.000 tekens, vertaal de volledige content in één aanroep met hoge tokenlimieten.
- Chunking fallback: Als volledige vertaling mislukt, val automatisch terug op het opsplitsen van content in ~1.500 tekens lange chunks.
- Slimme JSON-parsing: Behandel reacties die zijn ingepakt in markdown codeblokken (```json) die de AI soms retourneert.
Foutmelding
- Model fallback: Als het primaire model (gemini-2.5-flash-lite) niet beschikbaar is, schakel automatisch over naar gemini-1.5-flash-latest.
- Elegante degradatie: Als een chunk mislukt, gebruik dan de originele Engelse content voor dat gedeelte en ga verder.
- API beleefdheid: Voeg vertragingen toe tussen aanroepen om rate limits te respecteren.
De belangrijkste les: Stel altijd
maxOutputTokens
expliciet in. De standaardlimieten zijn te laag voor real-world content, wat leidt tot mysterieuze parsingfouten die lijken op API-problemen, maar eigenlijk tokenlimietproblemen zijn.
Tips als je dit probeert
- Schrijf eenvoudig. Korte zinnen vertalen goed.
- Zet codeblokken af en wikkel inline code in
<code>
. - Vermijd woordspelingen als je de doeltekst niet zult controleren.
- Gebruik semantische HTML. Koppen en lijsten helpen lezers en modellen.
- Houd je broncontent schoon en goed gestructureerd.
FAQ
Bewerk ik vertalingen?
Alleen voor Spaans, aangezien dat de enige taal is die ik naast Engels ken. Voor titels en intros wil ik een snel startpunt, dan poets ik waar het ertoe doet. Voor andere talen vertrouw ik op de kwaliteit van de AI-vertaling.
Privacy?
Content gaat naar de Gemini API. Ik stuur geen geheimen. API-sleutels staan in
.env.local
(
GOOGLE_API_KEY
of
GEMINI_API_KEY
).
Modellen?
Ik gebruik een snel Gemini-model en val automatisch terug als het niet beschikbaar is. Je kunt dit overschrijven met
GEMINI_MODEL
.
Hoe zit het met de oude URL's?
Ik houd de oude URL's werkend met redirects. Het slug mapping systeem behandelt dit automatisch.
Conclusie
AI vervangt je stem niet. Het verwijdert het saaie werk. Houd je bron schoon, automatiseer de herhaalbare delen, valideer en verzend. Het script dat ik heb gedeeld behandelt de complexiteit, zodat jij je kunt concentreren op schrijven.