Extract, Translate, and Switch Languages
Keep your translation catalog current with the ForgeWP CLI, configure translation providers like DeepL and LibreTranslate, build accessible language switchers, and handle multilingual forms.
Once your components are instrumented with useWpI18n() and __(), you need a reliable way to collect those strings, populate translated text across all configured locales, and allow visitors to switch between languages in your theme UI.
ForgeWP provides dedicated CLI commands and runtime hooks to automate this entire lifecycle:
pnpm forgewp i18n:extract: Scans your React codebase, collects all translation keys, and synchronizes yourcms/translations.tscatalog without overwriting existing translations.pnpm forgewp i18n:translate: Connects to automated translation providers (DeepL, Google Translate, LibreTranslate, or local/manual) to translate untranslated keys at build time.useWpLanguage(): Provides active language state, language-specific URLs, and navigation helpers to build accessible language switchers.
Extract Translation Keys from Source Code
As you build and update React components, new translatable strings are added to JSX templates. Instead of manually copying strings into your translation file, run the ForgeWP extraction tool:
pnpm forgewp i18n:extractHow the Extractor Works
When you run i18n:extract, the CLI performs the following steps:
- Scans Source Code: Recursively inspects all
.ts,.tsx,.js, and.jsxfiles inside thesrc/directory. - Collects
__()Invocations: Uses AST pattern matching to extract string arguments passed to__('...')and__("..."), automatically resolving escaped quotes. - Reads Configuration: Loads
wp.config.tsto identify thedefaultLocaleand the full list oflocales. - Merges Without Overwriting:
- For the default locale (e.g.
'en'), new keys are initialized with the key itself as the value. - For secondary locales (e.g.
'de','fr'), new keys are initialized as empty strings ("") ready to be translated. - Existing translations are untouched: The extractor never overwrites text you or a translator have already completed.
- Unused keys are preserved: If a string was removed from React source code, its translation remains in the catalog so previous work is never lost.
- For the default locale (e.g.
- Alphabetical Sorting: Keys are sorted alphabetically within each locale to guarantee clean, readable git diffs.
- File Writing: Writes directly to
cms/translations.ts(orcms/translations.jsonif your project uses JSON).
Example Terminal Output
FORGEWP I18N EXTRACTOR
Scanning files in: src/
Extracted 42 unique translation keys.
----------------------------------------
Extraction completed successfully!
File: cms/translations.ts
Locales: en, de, fr, it
New keys discovered: 6
Unused keys remaining in catalog: 0 (retained to prevent work loss)Before and After Catalog Comparison
Notice how running extraction adds the new keys while preserving completed translations:
1export const translations = defineTranslations({
2 en: {
3 "All rights reserved.": "All rights reserved.",
4 "Book Your Stay": "Book Your Stay",
5+ "Check availability": "Check availability",
6 "Discover Luxury": "Discover Luxury",
7 },
8 de: {
9 "All rights reserved.": "Alle Rechte vorbehalten.",
10 "Book Your Stay": "Aufenthalt buchen",
11+ "Check availability": "",
12 "Discover Luxury": "Luxus entdecken",
13 },
14 fr: {
15 "All rights reserved.": "Tous droits reserves.",
16 "Book Your Stay": "Reservez votre sejour",
17+ "Check availability": "",
18 "Discover Luxury": "Decouvrez le luxe",
19 },
20 })Translate Missing Entries with pnpm forgewp i18n:translate
Once missing keys are extracted as empty strings, you can translate them automatically using:
pnpm forgewp i18n:translateCore Architecture Principles
- Build-Time Only: Auto-translation happens strictly at build/CLI time. No external translation API requests are ever made while visitors browse your live WordPress site.
- Catalog First: Existing nonempty catalog values are preserved. An optional response cache can restore blank entries before contacting the configured provider.
- Saved Translation Data: The theme reads saved translations rather than calling a machine-translation provider for every visitor. Other site features retain their own network and hosting requirements.
Supported Translation Providers
ForgeWP supports four translation providers configured in wp.config.ts:
Provider Name Internet Required? Best For Local / Manual 'local' or 'manual'No Agency handoffs, professional copywriters, custom manual editing. DeepL API 'deepl'At build time Premium natural language translation quality (especially European languages). Google Cloud 'google'At build time Maximum global language coverage across 100+ languages. LibreTranslate 'libretranslate'Optional (Local Docker) Privacy-conscious teams, self-hosted offline translation pipelines. |
|---|
1. Local / Manual Mode (Default)
In local mode, the CLI leaves missing keys as empty strings so you or your localization team can fill them in manually:
1import { defineConfig } from "@forgewp/compiler/define-config"
2
3export default defineConfig({
4 name: "Alpine Resort",
5 slug: "alpine-resort",
6 i18n: {
7 locales: ["en", "de", "fr"],
8 defaultLocale: "en",
9 provider: "local",
10 },
11})When you run pnpm forgewp i18n:translate with provider: "local", ForgeWP verifies that all keys exist and reports which keys need manual translation in cms/translations.ts.
2. DeepL API
DeepL offers the highest translation accuracy for marketing and editorial content. ForgeWP supports both DeepL API Free (keys ending in :fx) and DeepL API Pro.
Configure DeepL in wp.config.ts:
1import { defineConfig } from "@forgewp/compiler/define-config"
2
3export default defineConfig({
4 name: "Alpine Resort",
5 slug: "alpine-resort",
6 i18n: {
7 locales: ["en", "de", "fr", "it", "es"],
8 defaultLocale: "en",
9 provider: "deepl",
10 },
11})Set your API key in an environment variable or .env.local file:
FORGEWP_DEEPL_API_KEY=your-deepl-api-key-here:fxAlternatively, pass the key via providerConfig in wp.config.ts:
1i18n: {
2 locales: ["en", "de", "fr"],
3 defaultLocale: "en",
4 provider: "deepl",
5 providerConfig: {
6 apiKey: process.env.FORGEWP_DEEPL_API_KEY,
7 },
8}ForgeWP automatically handles target language code mapping (for example, mapping en to EN-US as required by the DeepL API specification).
3. Google Cloud Translation API
For languages not yet covered by DeepL, Google Cloud Translation offers support for over 100 languages.
1import { defineConfig } from "@forgewp/compiler/define-config"
2
3export default defineConfig({
4 name: "Alpine Resort",
5 slug: "alpine-resort",
6 i18n: {
7 locales: ["en", "ja", "ko", "zh"],
8 defaultLocale: "en",
9 provider: "google",
10 },
11})Provide your API key in .env.local:
FORGEWP_GOOGLE_API_KEY=your-google-api-key-here4. LibreTranslate (Self-Hosted & Open Source)
For enterprise organizations, air-gapped systems, or agencies that need complete data privacy, LibreTranslate is a free, open-source machine translation engine that you can run locally via Docker:
docker run -ti -p 5000:5000 libretranslate/libretranslateConfigure ForgeWP to point to your local instance:
1import { defineConfig } from "@forgewp/compiler/define-config"
2
3export default defineConfig({
4 name: "Alpine Resort",
5 slug: "alpine-resort",
6 i18n: {
7 locales: ["en", "de", "fr"],
8 defaultLocale: "en",
9 provider: "libretranslate",
10 providerConfig: {
11 endpoint: "http://localhost:5000",
12 },
13 },
14})Running pnpm forgewp i18n:translate will now send translation requests to your local Docker container with zero internet connectivity required.
Build Language Switchers with useWpLanguage()
To let visitors choose their preferred language, import the useWpLanguage() hook from @forgewp/react.
Properties Returned by useWpLanguage()
Property Type Description currentLanguagestringThe active language code (e.g. 'en', 'de').languagesstring[]Array of all supported language codes declared in your configuration or Polylang. urlsRecord<string, string>Map of language codes to their specific URLs for the current page (e.g. { en: '/about', de: '/de/ueber-uns' }).homeUrlsRecord<string, string>Map of language codes to their respective homepage URLs (e.g. { en: '/', de: '/de/' }).homeUrlstringHomepage URL for the currently active language. switchLanguage(lang: string) => voidNavigation function that switches to the target language URL while preserving existing query parameters ( window.location.search). |
|---|
1. Server-Safe Static Language Switcher (0 KB JavaScript)
If your language switcher consists of standard navigation links (<a href="...">), ForgeWP leaves the component as 100% server-side rendered PHP HTML. No client hydration island is created, and zero JavaScript is sent to the browser:
1import { useWpLanguage } from "@forgewp/react"
2
3export function StaticLanguageSwitcher() {
4 const { currentLanguage, languages, urls } = useWpLanguage()
5
6 return (
7 <nav
8 aria-label="Language"
9 className="flex items-center gap-2 text-sm font-medium"
10 >
11 {languages.map((lang) => {
12 const isCurrent = lang === currentLanguage
13 const targetUrl = urls[lang] || `/${lang}/`
14
15 return (
16 <a
17 key={lang}
18 href={targetUrl}
19 aria-current={isCurrent ? "page" : undefined}
20 className={`rounded px-2 py-1 uppercase transition ${
21 isCurrent
22 ? "bg-emerald-500/10 font-bold text-emerald-600"
23 : "text-slate-600 hover:text-slate-900"
24 }`}
25 >
26 {lang}
27 </a>
28 )
29 })}
30 </nav>
31 )
32}Because <a href> links rely on standard browser navigation, this component requires zero client-side JavaScript.
2. Interactive Dropdown Switcher with switchLanguage()
When you need an interactive select element, dropdown menu, or custom popover, call switchLanguage(). ForgeWP automatically detects the click handler and selectively hydrates the switcher as an isolated client island:
1import { useState } from "react"
2import { useWpLanguage, useWpI18n } from "@forgewp/react"
3
4const LANGUAGE_LABELS: Record<string, string> = {
5 en: "English",
6 de: "Deutsch",
7 fr: "Francais",
8 it: "Italiano",
9}
10
11export function LanguageDropdown() {
12 const [isOpen, setIsOpen] = useState(false)
13 const { currentLanguage, languages, switchLanguage } = useWpLanguage()
14 const { __ } = useWpI18n()
15
16 return (
17 <div className="relative inline-block text-left">
18 <button
19 type="button"
20 onClick={() => setIsOpen((prev) => !prev)}
21 aria-expanded={isOpen}
22 aria-haspopup="true"
23 aria-label={__("Select language")}
24 className="flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-xs hover:bg-slate-50 focus:outline-none"
25 >
26 <span className="font-semibold uppercase">{currentLanguage}</span>
27 <span>{LANGUAGE_LABELS[currentLanguage] || currentLanguage}</span>
28 <svg
29 className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
30 fill="none"
31 stroke="currentColor"
32 viewBox="0 0 24 24"
33 >
34 <path
35 strokeLinecap="round"
36 strokeLinejoin="round"
37 strokeWidth={2}
38 d="M19 9l-7 7-7-7"
39 />
40 </svg>
41 </button>
42
43 {isOpen && (
44 <div className="ring-opacity-5 absolute right-0 z-50 mt-2 w-44 rounded-lg border border-slate-200 bg-white py-1 shadow-lg ring-1 ring-black">
45 {languages.map((lang) => {
46 const isCurrent = lang === currentLanguage
47
48 return (
49 <button
50 key={lang}
51 type="button"
52 onClick={() => {
53 setIsOpen(false)
54 switchLanguage(lang)
55 }}
56 className={`flex w-full items-center justify-between px-4 py-2 text-left text-sm transition ${
57 isCurrent
58 ? "bg-emerald-50 font-semibold text-emerald-700"
59 : "text-slate-700 hover:bg-slate-100"
60 }`}
61 >
62 <span>{LANGUAGE_LABELS[lang] || lang.toUpperCase()}</span>
63 {isCurrent && (
64 <svg
65 className="h-4 w-4 text-emerald-600"
66 fill="currentColor"
67 viewBox="0 0 20 20"
68 >
69 <path
70 fillRule="evenodd"
71 d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
72 clipRule="evenodd"
73 />
74 </svg>
75 )}
76 </button>
77 )
78 })}
79 </div>
80 )}
81 </div>
82 )
83}Preserving Search Parameters and Filters
switchLanguage() automatically preserves the current page's URL search parameters. If a visitor is browsing a hotel catalog with filters active:
/suites/?max_price=400&view=mountainCalling switchLanguage('de') navigates the visitor to:
/de/suiten/?max_price=400&view=mountainTheir filter selections, sorting parameters, and search keywords are preserved across language transitions.
Multilingual Forms and Client-Owned Field Sets
Handling internationalization in native forms requires distinguishing between two categories of form content:
- Developer-Owned Fields (
fields): Defined incms/forms/{name}.ts. Labels and placeholders are source strings translated via__()or the translation catalog. - Client-Owned Fields (
clientFields): Added and customized by content editors in the WordPress admin dashboard (Forms → Form Editor).
Configuring Per-Locale Client Field Seeds
When your theme supports multiple locales (i18n.locales.length > 1), pass a per-locale dictionary to clientFields.seed so each language initializes with appropriate localized field labels:
1import { defineWpForm } from "@forgewp/react/config"
2
3export const form = defineWpForm({
4 mailTo: "admin",
5 subject: "New Chalet Inquiry",
6 submissionTitle: "Inquiry - {name}",
7
8 // Developer-owned fields (translated via useWpI18n / gettext)
9 fields: {
10 name: { type: "text", label: "Full Name", required: true },
11 email: { type: "email", label: "Email Address", required: true },
12 message: { type: "textarea", label: "Message", required: true },
13 },
14
15 // Client-owned fields (seeded per-locale, managed in wp-admin)
16 clientFields: {
17 enabled: true,
18 seed: {
19 en: [
20 {
21 name: "guest_count",
22 label: "Number of Guests",
23 type: "number",
24 required: true,
25 },
26 { name: "room_type", label: "Preferred Suite", type: "text" },
27 ],
28 de: [
29 {
30 name: "guest_count",
31 label: "Anzahl der Gaste",
32 type: "number",
33 required: true,
34 },
35 { name: "room_type", label: "Bevorzugte Suite", type: "text" },
36 ],
37 fr: [
38 {
39 name: "guest_count",
40 label: "Nombre de personnes",
41 type: "number",
42 required: true,
43 },
44 { name: "room_type", label: "Suite preferee", type: "text" },
45 ],
46 },
47 },
48})How submitWpForm() Validates by Language
When a visitor submits a native form on a German page, submitWpForm() automatically transmits the visitor's page locale:
1{
2 "name": "Johannes Weber",
3 "email": "johannes@example.de",
4 "guest_count": 4,
5 "lang": "de"
6}The WordPress server-side submission handler calls forgewp_forms_resolve_lang($submitted_lang):
- Loads the exact client-owned field definition registered for the German (
de) tab in the admin. - Validates field names, types, and required rules against what the visitor actually saw.
- Saves the submission in the
forgewp_submissioncustom post type tagged with thedelanguage attribute.
Best Practices for Multilingual Themes
1. Use Complete Natural Sentences as Keys
Avoid assembling translated sentences out of individual words:
1// Never assemble phrases from separate translated fragments:
2<span>
3 {__("We have")} {count} {__("rooms available")}
4</span>Instead, use formatted strings with placeholders:
1<span>{__("We have %s rooms available").replace("%s", String(count))}</span>Translating complete sentences gives human translators and automated engines the grammatical context required for correct word order, declensions, and agreements.
2. Keep Keys Consistent Across Components
The translation key is case-sensitive and whitespace-sensitive:
1__("Book Your Stay") // Matches catalog key "Book Your Stay"
2__("Book your stay") // Treated as a separate, distinct key!Pick a consistent capitalization convention for buttons and navigation items across your design system.
3. Provide hreflang and Canonical SEO Tags
Ensure your site layout outputs language alternates for search engines using useWpLanguage:
1import { useWpLanguage } from "@forgewp/react"
2
3export function LanguageMetaTags() {
4 const { languages, urls } = useWpLanguage()
5
6 return (
7 <>
8 {languages.map((lang) => (
9 <link
10 key={lang}
11 rel="alternate"
12 hrefLang={lang}
13 href={urls[lang] || `/${lang}/`}
14 />
15 ))}
16 </>
17 )
18}Troubleshooting & Common Pitfalls
Problem: Running i18n:extract does not discover my strings
Cause: The strings were passed as template literals with expressions (__(Hello ${name})) rather than static string literals.
Fix: The AST extractor requires static strings (__("Hello")). Use string concatenation or post-replacement for variables: __("Hello").concat(" ", name).
Problem: DeepL translation fails with "API error 403"
Cause: Using a Free API key with the Pro endpoint, or missing the :fx suffix.
Fix: Verify your key in .env.local. If you have a DeepL Free plan, your key must end in :fx (for example, 38b4...:fx). ForgeWP automatically selects the free endpoint (api-free.deepl.com) when :fx is present.
Problem: My Language Switcher component is hydrates even when I only wanted static links
Cause: An onClick event handler was attached to navigation elements.
Fix: If you only need visitors to follow links to other language pages, use standard <a href={urls[lang]}> links instead of onClick={() => switchLanguage(lang)}. ForgeWP's Smart Discovery will leave the component as 100% server-side rendered PHP HTML.
Problem: Editing translations in wp-admin has no effect
Cause: The Polylang String Translations cache has not been refreshed.
Fix: After updating catalog files or exporting your theme, visit Languages → String Translations in the WordPress admin to let Polylang detect updated strings registered via pll_register_string.
Frequently Asked Questions
Can I mix DeepL auto-translation with manual revisions?
Yes. When you run pnpm forgewp i18n:translate, DeepL populates untranslated keys. You can open cms/translations.ts at any time and revise specific phrases. ForgeWP will never overwrite strings you have edited.
How does ForgeWP handle Right-to-Left (RTL) languages like Arabic or Hebrew?
WordPress automatically injects the rtl class into the <body> element when an RTL locale is active. You can style RTL layouts using CSS logical properties (margin-inline-start, padding-inline-end) or Tailwind's rtl: variant.
Does ForgeWP require Polylang to be installed on WordPress?
No. Polylang is optional. If Polylang is not installed, ForgeWP resolves translations through native WordPress gettext (<?php echo __('Text', 'theme'); ?>) and the active site locale from get_locale(). Installing Polylang enhances your site with per-post language linking and admin UI string editing.
Next Steps
Now that you have configured internationalization, extracted translation keys, and implemented language switching, proceed to Exporting and Production Deployment to learn how to build, validate, and package your production WordPress theme.
Reuse successful translation responses
Add a cache path when the translation command should reuse previously generated translations even if a catalog entry becomes blank again:
1i18n: {
2 locales: ["en", "fr"],
3 defaultLocale: "en",
4 provider: "deepl",
5 cache: ".forgewp/i18n-cache.json",
6 providerConfig: {
7 apiKey: process.env.FORGEWP_DEEPL_API_KEY,
8 },
9}Run pnpm forgewp i18n:translate after extracting keys. For each target language, the command preserves existing catalog values, restores matching cached values for blank entries, then asks the provider only for remaining misses. Successful nonempty responses are saved after each language batch, so an earlier batch remains reusable if a later provider request fails.
The cache distinguishes provider, configured endpoint, source language, target language, and exact source text. API credentials are not written into its keys or metadata. Changing a source string, language pair, provider, or endpoint creates a different lookup. Rotating an API key does not invalidate previously cached results.
The cache is optional: omitting cache keeps the normal catalog-only workflow. Local/manual mode does not read or write the response cache. The configured path must be a project-relative .json file inside the theme and separate from cms/translations.json and package.json. An invalid existing cache stops the remote translation command with a recovery message rather than overwriting that file.
The catalog remains the source of truth: an edited, nonempty translation takes precedence over a cached response. To request a fresh machine translation, clear the intended target entry and remove the response cache or choose a new cache path before running the command. Removing a cache alone does not replace existing catalog translations. A cache under .forgewp/ is removed by forgewp clean; the CMS translation catalog is separate.