Localization Is a System, Not a Translation Task
Internationalization and localization go far beyond publishing translated copies of your content. A robust strategy needs to decide which locale variant gets served and include the code to make that decision. You have to support languages and regional differences within the same language, and make your UI responsive to content, not just viewport size. That means structuring everything from microcopy to date formats so it can adapt to any target language. Static site generators add another constraint: there is no database and no server to handle requests at runtime. It is all achievable, but it demands deliberate planning.
When we built chromeOS.dev, the goal was to serve a global audience without hand-coding each language version. Two requirements shaped the architecture:
- Support multiple locales — language, region, or a combination — in a single codebase without bespoke implementations per locale.
- Allow translators to work with minimal knowledge of the underlying system, while content creators focus on authoring rather than deployment mechanics.
Balancing those needs is the core challenge of internationalizing a codebase and localizing a site.
Two Sides of the Same Coin
Internationalization (i18n) and localization (l10n) are complementary disciplines. Internationalization is the engineering work: designing software so it can be adapted to multiple languages and regions without code changes. Localization is the application of that work: the actual adaptation of content and presentation for a specific language or region. In a web project, internationalization touches the full stack — HTML, CSS, JavaScript, design decisions, and the build system — while localization concentrates in content creation and management, including both long-form copy and UI microcopy.
Choosing a Localization Strategy
Internationalization on static sites comes down to three core questions: how the user’s preferred language is determined, how content gets served in that language, and how the interface adapts to the chosen localization. Dynamic sites can resolve these questions at request time, but static sites need to bake decisions into the build. The fundamentals are the same either way.
How Users Signal Their Language
Before any content can be served, you need a policy for deciding which localization a visitor receives. The common options are:
- Geolocation via IP address;
- The
Accept-Languageheader ornavigator.languagesproperty; - A language marker embedded in the URL.
Many implementations mix these approaches, but each has weaknesses. IP geolocation assumes a user’s language matches their physical location, which is frequently untrue. It is also technically unreliable and can block search engine crawling. The Accept-Language header is often never explicitly configured by users and carries only language, not region information, making it a weak signal for a definitive guess. Headers may still be useful for an initial heuristic, but not as the source of truth.
URL-based identifiers avoid these problems by making localization explicit and shareable. Three URL patterns are common:
- Distinct domains or subdomains per language, such as
example.comandexample.de; - Subdirectories per language, such as
example.com/enandexample.com/de; - Query parameters, such as
example.com?loc=en.
Query parameters are widely discouraged because users can’t easily recognize the localization and because they complicate analytics and content management. Distinct domains are problematic for Progressive Web Apps: each TLD or subdomain is its own origin, requiring a separate PWA per language.
Subdirectories offer a compromise. With subdirectories, a single PWA can serve both language-only localizations (example.com/en) and combined language-and-region paths (example.com/en-US). If every localization gets its own subdirectory — rather than elevating a default language to the root — and URLs outside that subdirectory stay identical regardless of language, users can switch languages without translating or re-requesting URLs.
Serving Content According to Preference
Once users can signal a preference, you need a mechanism to store it and redirect them when they land on the wrong localization. Cookies, local storage, or app-level logic can hold that preference, but those options tie you to a specific hosting or server setup. Service workers provide a server-agnostic alternative.
For a static PWA, the flow can work like this: when a first-time user arrives, the service worker isn’t yet installed, so whatever localization they hit becomes their stored preference in IndexedDB. If they land without a localization set, the site’s default language is used instead. A footer language switcher lets users change this. Once the service worker is active, it intercepts navigation requests. Each URL is inspected to see if it contains a supported language subdirectory. If the requested path doesn’t match the user’s stored preference, the service worker issues a 302 redirect to the correct version; otherwise the page is served unchanged.
This logic can be packaged as a Workbox plugin. The Service Worker Internationalization Redirect plugin and its preferences sub-module handle reading and writing the user’s language choice. Combined with Workbox’s registerRoute and filtering on request.mode === 'navigate', the setup is minimal.
The client-side registration looks like this:
import { preferences } from 'service-worker-i18n-redirect/preferences';
window.addEventListener('DOMContentLoaded', async () => {
const language = await preferences.get('lang');
if (language === undefined) {
preferences.set('lang', lang.value); // Language determined from localization user landed on
}
});
And the corresponding service worker code:
import { StaleWhileRevalidate } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { i18nHandler } from 'service-worker-i18n-redirect';
import { preferences } from 'service-worker-i18n-redirect/preferences';
import { registerRoute } from 'workbox-routing';
// Create a caching strategy
const htmlCachingStrategy = new StaleWhileRevalidate({
cacheName: 'pages-cache',
plugins: [
new CacheableResponsePlugin({
statuses: [200],
}),
],
});
// Array of supported localizations
const languages = ['en', 'es', 'fr', 'de', 'ko'];
// Use it for navigations
registerRoute(
({ request }) => request.mode === 'navigate',
i18nHandler(languages, preferences, htmlCachingStrategy),
);
With both pieces in place, a first visit sets the preference automatically, and any subsequent navigation outside that preference triggers a redirect.
Adapting Interface Details
Beyond delivering content in the right language, smaller interface elements need attention.
Quotation marks around blockquotes are a subtle case. Different locales use different glyphs, so hard-coding quotes will break for non-default languages. The CSS open-quote and close-quote values let the browser insert the correct marks for the page’s language.
open-quote and close-quote for lang=“en” appear as two superscript commas facing inward towards the text, with the first pair inverted. (Large preview)open-quote and close-quote for lang=“fr” appear as a pair of chevrons with their openings facing inward towards the text. (Large preview)Dates and numbers can be localized with .toLocaleString() for both the Date and Number prototypes. Browsers bundle all locale data, so this works out of the box. Node.js does not, but the full-icu npm module fills the gap. After installing, run your code with the NODE_ICU_DATA environment variable pointing at the module, such as NODE_ICU_DATA=node_modules/full-icu.
Markup and Direction
Several pieces of HTML metadata must change with each localization:
- Language on the
htmlelement; - Writing direction on the
htmlelement; - Links indicating alternate language versions.
The first two live in the lang and dir attributes — for example, <html lang="en" dir="ltr"> for US English. Correct values ensure proper text flow and enable browser features like translation. The third uses rel="alternate" links with the hreflang attribute. Adding <link href="https://www.example.com/es" rel="alternate" hreflang="es"> to the English page signals to search engines that a Spanish translation exists.
Designing for Content, Not Just Viewports
Translations rarely occupy the same space as the original. German text tends to run wider; Arabic typefaces are often taller. Layouts that only respond to viewport width will fail. Intrinsic design, a term coined by Jen Simmons, addresses this by making components responsive to their content as well as the screen.
CSS content-based units are the foundation. The em unit tracks the element’s computed font-size and rem tracks the root font-size. Using these instead of fixed pixel values makes spacing scale with text. The ch unit, which equals the inline size of the “0” glyph in the current font, lets you tie layout widths to the actual content they contain.
These units pair well with flexbox and grid to build layouts that flow naturally at any size. Adding logical properties for margins, padding, and borders — rather than physical properties like margin-left or padding-top — makes those layouts automatically respect a page’s writing mode. For right-to-left scripts, logical properties swap inline and block axes appropriately. The combination of content-aware units, logical properties, and modern layout tools means interfaces can be built once and adapt to any language, not just any screen size.
Localization In Practice
Once the internationalization strategy is set for a static site, the next layer is determining what content actually gets translated and how that content is structured. The core distinction is between long-form content and the surrounding interface text that supports it.
Content Modeling For Translation
Translation targets are not uniform across a codebase. Structural elements like CSS class names and JavaScript variables are implementation details, not user-facing strings, so they stay out of the localization pipeline. What needs translation falls into two categories: content entities (articles, authors, documents) and microcopy (reusable interface strings like "Read More" or "Menu").
For content entities, the discipline of content modeling is invaluable. A content model defines the shape of an entity—its fields and relationships—independent of any particular language. A blog post model, for instance, might include a title, an array of tags, a reference to an author, a publish date, and the body. It should not embed strings for breadcrumbs or duplicate author details that belong to a separate author model. These models are structural and locale-agnostic; an instance of a model gets localized, but the model itself is universal.
Microcopy is where localization gets messy. Unlike content entities, which often have natural structures, microcopy is frequently written ad hoc directly into templates, making it nearly impossible to manage translations. The fix is to treat microcopy with the same rigor as content models. Defining microcopy models for strings like disclaimers, button labels, and navigation titles gives translators a clear, bounded set of strings, so the actual translation work never leaves the pipeline.
Localize Values, Not Keys
Data structures built from these models—JSON objects, YAML files, Front Matter—must follow one cardinal rule: never translate the object keys. A string stored at microcopy.search.text should not become microcopie.chercher.texte in a French locale. Keys are neutral identifiers that templates and code rely on; they must remain stable and consistent. Translations belong in the values, never in the structure that references them.
Site Architecture For Multiple Locales
The folder structure of a static site generator can double as the localization architecture. This is particularly effective when the generator compiles pages based on directory layout. For the chromeOS.dev build, this was implemented with Eleventy, but the underlying pattern transfers to most other static site generators.
At the top level sits a pages directory containing everything destined for output. Inside it, a _data folder holds global data files, and a _generated folder contains templates for pages that are highly templated or assembled from content and microcopy rather than written out as individual files. Underscore-prefixed folders are common conventions in Eleventy and similar tools; they signal that the folder is a source for data or logic, not an output directory itself.
The localization structure emerges one level down:
.
└── pages
├── _data
├── _generated
└── {{locale-code}}
├── {{locale-code}}.11tydata.js
├── _data
└── [...content]
Each locale gets its own subdirectory, named by its BCP47 language tag—en for English, en-US for American English. Eleventy’s data cascade, particularly directory data files, means a file like en/en.11tydata.js can inject data variables readably available to every file within that locale’s subtree.
Microcopy data typically lives in a _data folder nested inside each locale’s directory. This keeps files like locale.json (language code and writing direction), newsletter.yml, and microcopy.yml scoped to their language while remaining globally accessible to templates through Eleventy’s data injection. The chromeOS.dev project built a helper module called l10n-data for exactly this purpose, taking the folder structure and producing a cascaded, incrementally localizable data object.
Long-form content files go directly into the locale folder. One important decision: treat paths and file names as locale-agnostic. A file at en/web/pwas.md outputs to en/web/pwa, and stays inside the same relative folder in other locales even if its content is translated. This keeps the localized versions of a page trivially findable and avoids any temptation to localize URL keys, which only confuses the relationship between content instances.
Template Helpers For Localization
Localized content and microcopy require some plumbing to make templating efficient. In 11ty, filters modify content before rendering. The chromeOS.dev project built a handful of core filters for this purpose.
A date filter standardizes on YAML timestamps in source files. With the full-icu module enabled, the filter passes the date value and the current locale code to Date.toLocaleString to produce a localized date display. For date-only output, Date.toLocaleDateString is an alternative. A separate filter, called localURL, handles rewriting internal URLs between locales, changing /en/linux to /es/linux.
Two other filters resolve locale codes into human-readable information. Using the iso-639-1 module, one filter converts a locale code into the language name written in that language, which powers the language selector UI. Another uses i18n-iso-countries to produce a localized list of country names for web forms.
Beyond filters, custom collections group content per locale. Rather than filtering the entire site’s content inside templates, the chromeOS.dev codebase built dedicated helper functions to return locale-specific tag collections and section collections. This approach scales cleaner as content grows.
The final and most consequential piece is a global data file that ties all this together. It reads the locale-based directory structure, automatically discovering the list of supported localizations. The resulting global site object exposes an l10n property containing all the localized microcopy, and a languages property listing available locales. Crucially, it also serves as the bridge to the front-end: it emits JavaScript defining the supported languages and the per-locale data entries for browser scripts to import. This single data source enables programmatic page generation, allowing localized home and landing pages to be generated by looping over site.l10n rather than hand-maintaining a separate HTML file for every language version.
Making i18n Sustainable
The difficulty of internationalization and localization is largely determined by the strategy you choose at the outset. For static sites, a subdirectory-based approach is the most natural fit. From that foundation, build tooling that automates as much of the translation and content production process as possible.
A well-defined content and microcopy model is essential. When authors and translators work from structured, predictable patterns, maintaining multiple locales stops feeling like a separate project and starts behaving like a single-locale workflow with a few extra fields.
Serverless Localization With Service Workers
One technique worth considering is using service workers to perform client-side localization. This approach keeps the server agnostic to locale entirely. The service worker intercepts requests and serves the appropriate localized assets, which can simplify deployment and caching strategies significantly.
Content-Responsive Design
Localization does not end at text replacement. Design must respond not only to viewport dimensions but to the actual content being rendered. Languages vary dramatically in average word length, reading direction, and typographic conventions. A layout that comfortably fits German prose may break with Japanese or Arabic. Build layouts that accommodate these variations fluidly rather than hoping they never occur.
When the i18n strategy, content models, tooling, and design work together, the result is a site that feels native to every user, regardless of locale. The maintenance burden for authors and translators remains low, and the experience stays consistent across the board.



