A client's Swedish B2B platform needed to speak English and Danish. The site was built Swedish-first for the Nordic workplace safety market, and now it's expanding internationally.
The challenge isn't just text replacement. It's finding every hardcoded string, understanding what belongs where, and doing it without breaking a production site.
The Technical Foundation
The platform runs on Next.js 15 with the App Router. For internationalization, we chose next-intl version 4—it handles the complexity of server and client component boundaries that makes i18n in React Server Components particularly tricky.
The architecture uses locale-based routing, where the language preference becomes part of the URL structure itself.
When a user visits the site, middleware intercepts the request and examines the browser's language preferences. Based on this detection—or a previously stored preference—the system redirects to the appropriate locale prefix. All pages live under a dynamic locale segment in the app directory, allowing the same page component to render content in any supported language.
The Translation File System
Translations live in a messages directory at the project root, with one JSON file per supported locale. Swedish serves as the primary language since the site was built Swedish-first. English and Danish translations reference the Swedish structure to ensure complete coverage.
Each translation file is organized by namespace—logical groupings that correspond to features or page sections. For example, the drug testing page uses a dedicated namespace containing all its text content: hero section titles, feature descriptions, benefit lists, and calls to action. This organization makes it easy to find and update related strings without hunting through a monolithic file.
The structure uses nested objects to keep related content together. Benefits, sample types, and feature descriptions each get their own sub-object within the namespace. Accessing these nested keys uses dot notation in the translation function calls, making the code readable and the relationships between strings clear.
The Server-Client Component Divide
Here's where modern React adds complexity. Next.js 15 defaults to Server Components, which render on the server and ship minimal JavaScript to the browser. But the primary translation hook from next-intl requires client-side rendering because it uses React context internally.
This means every component that needs to display translated text must be marked as a client component. Forgetting this directive produces a cryptic error about hooks being called outside a component context—not immediately obvious when you're neck-deep in translation work.
The pattern becomes second nature: any component file using the translation hook starts with the client directive, imports the hook from next-intl, then calls it with the relevant namespace at the top of the component function.
The Hunt for Hardcoded Strings
This was the bulk of the work. Swedish text scattered across components like confetti. The systematic approach matters because casual searching misses things.
We found issues in layers. The first pass caught the obvious headings and paragraphs—the text users see immediately. The second pass revealed form placeholders and button labels. The third pass uncovered aria-labels in the navigation and screen reader text that sighted users never see but that matters for accessibility.
The process is archaeological. You dig through layers of code, finding strings that were written quickly during initial development. The mix of Swedish and English—sometimes in the same component—tells the story of a site built iteratively over time.
A Case Study: The Products Component
The home page products section was entirely hardcoded. Swedish headlines mixed with English benefit text, likely from copy-pasting during development. The component contained over forty distinct strings: section titles, benefit bullet points, feature descriptions, product type names for saliva, urine, and blood tests, sample feature lists, and product category cards.
Converting this single component required creating a new translation namespace and extracting every string into the appropriate nested structure. The Swedish version preserves the original text exactly. The English and Danish versions required actual translation work—understanding the context of each phrase to produce accurate equivalents.
The translation file for this namespace grew to handle the complexity: top-level keys for major headings, nested objects for benefits grouped by category, and deeply nested structures for product variants where each test type has its own name, description, and feature list.
The Contact Form Email Challenge
One subtle issue emerged with the contact form. When someone submits the form, the server sends an email notification. The original implementation had Swedish labels hardcoded directly in the server action—text like "New contact form submission" and field labels like "Name" and "Company" in Swedish.
Server actions can't use the translation hook. They're not React components; they're server-side functions that execute in response to form submissions. The hook relies on React context that doesn't exist in this environment.
The solution passes translations from client to server. The client component fetches the necessary email labels using the translation hook, then includes them as part of the form submission payload. The server action receives these pre-translated strings and uses them to construct the email. Not the most elegant pattern, but it works and keeps emails properly localized based on the user's language preference.
Remaining Work
After two thorough passes, we caught most user-facing content. What remains falls into distinct categories with different urgency levels.
The accessibility attributes are quick fixes—just more strings to extract and translate. The product catalog and blog articles represent a fundamentally different challenge. Those aren't UI strings; they're content that should live in a headless CMS where content editors can manage translations without touching code. That's a larger architectural decision for another day.
Build Verification
Every translation change needs a build check. Missing translation keys, typos in key names, or import errors often don't surface until build time. The Next.js compiler catches missing translations and type errors in translation calls, making the build process an essential verification step.
We run the build after each batch of changes. It's slower than just checking the dev server, but it catches problems that would otherwise slip through to production.
Lessons Learned
Translation work reveals the history of a codebase. The mixed Swedish and English, the inconsistent patterns, the strings written quickly during initial development—they all tell a story of iterative building under time pressure.
Each layer reveals more strings. You think you're done, then find another batch of hardcoded text in a component you forgot about. The systematic approach—working through layers rather than hunting randomly—ensures nothing gets missed.
The good news: once the infrastructure is in place, adding new languages becomes pure translation work. The code doesn't need to change. You add a new JSON file, update the locale configuration, and the same components serve content in yet another language. The investment in proper i18n architecture pays dividends as the platform expands to new markets.