Today was a translation marathon. A client's Swedish B2B platform - built for the Nordic workplace safety market - needed to go international. The site was built in Swedish first, and now it needs to speak English and Danish too.
The problem isn't just text replacement. It's finding every hardcoded string, understanding what belongs where, and doing it without breaking a production site.
The Stack
The platform runs on Next.js 15 with the App Router. For i18n, we're using next-intl v4 - it handles the complexity of server/client component boundaries that makes internationalization in React Server Components tricky.
The architecture uses locale-based routing:
All pages live under app/[locale]/. The middleware intercepts requests and redirects to the appropriate locale prefix based on browser settings or previous selection.
The Translation System
Translation files live in /messages/ - one JSON file per locale:
messages/
├── sv.json # Swedish (primary)
├── en.json # English
└── da.json # Danish
Each file is organized by namespace. A component like the drug testing page uses the drugTesting namespace:
{
"drugTesting": {
"hero": {
"title": "Drug Testing for Workplaces",
"subtitle": "Comprehensive testing solutions for a safer work environment"
},
"whyMatters": {
"title": "Why Drug Testing Matters",
"imageAlt": "Professional workplace drug testing"
}
}
}Server vs Client Components
This is where it gets interesting. Next.js 15 defaults to Server Components, but next-intl's useTranslations hook requires client-side rendering. The pattern we use:
"use client";
import { useTranslations } from "next-intl";
export default function DrugTestingPage() {
const t = useTranslations("drugTesting");
return (
<h1>{t("hero.title")}</h1>
);
}The "use client" directive is required whenever you call useTranslations. Forgetting it gives you a cryptic error about hooks being called outside a component.
The Hunt for Hardcoded Strings
This was the bulk of the work. Swedish text scattered across components like confetti. The process:
- Grep for Swedish characters -
å,ä,öare dead giveaways - Check alt text on images - Often forgotten
- Form placeholders - Easy to miss
- Accessibility attributes -
aria-label,sr-onlytext - Error messages - Usually hardcoded in handlers
We found issues in layers. First pass caught the obvious headings and paragraphs. Second pass found form placeholders. Third pass caught aria-labels in the header navigation.
Products Component: A Case Study
The home page products section was entirely hardcoded. Here's what it looked like before:
// Before - hardcoded Swedish and English mixed
<h5 className="text-lg font-medium text-[#192E54]">
Drogtestning - Första steget till
</h5>
<p className="text-sm text-[#475569]">
Reduced absence
</p>And after translation:
"use client";
import { useTranslations } from "next-intl";
export default function Products() {
const t = useTranslations("homeProducts");
return (
<h5 className="text-lg font-medium text-[#192E54]">
{t("drugTestingTitle")}
</h5>
<p className="text-sm text-[#475569]">
{t("benefits.reducedAbsence")}
</p>
);
}The translation namespace homeProducts ended up with 40+ keys covering:
- Section titles
- Benefit bullet points
- Feature descriptions
- Product type names (saliva, urine, blood)
- Sample features
- Product category cards
Translation File Structure
We organized translations hierarchically. Nested objects keep related content together:
{
"homeProducts": {
"drugTestingTitle": "Drogtestning - Första steget till",
"benefits": {
"reducedAbsence": "Minskad frånvaro",
"reducedRisk": "Minskad risk för skador",
"healthierStaff": "Friskare personal",
"workEfficiency": "Ökad arbetseffektivitet"
},
"sampleTypes": {
"saliva": {
"name": "Saliv",
"description": "Snabb och enkel provtagning..."
},
"urine": {
"name": "Urin",
"description": "Längst detektionstid..."
},
"blood": {
"name": "Blod",
"description": "Högsta precision..."
}
}
}
}Access nested keys with dot notation: t("sampleTypes.saliva.name").
The Contact Form Email Problem
One subtle issue: the contact form sends emails via Resend. The email body labels were hardcoded in a server action:
// Before - Swedish labels in server action
return await resend.emails.send({
subject: "Nytt kontaktformulär",
html: `
Namn: ${contact.name}
Företag: ${contact.company}
`
});Server actions can't use useTranslations - they're not React components. The solution: pass translations from the client:
// Client component
const handleSubmit = async () => {
await sendContactEmail({
...formData,
translations: {
subject: tEmail("subject"),
name: tEmail("name"),
company: tEmail("company"),
},
});
};// Server action
export const sendContactEmail = async (contact: {
// ... form fields
translations: {
subject: string;
name: string;
company: string;
};
}) => {
const t = contact.translations;
return await resend.emails.send({
subject: t.subject,
html: `${t.name}: ${contact.name}`,
});
};The client fetches translations, passes them to the server. Not elegant, but it works and keeps emails localized.
Remaining Work
After two passes, we've caught most user-facing content. What's left:
The accessibility attributes are quick fixes. The product catalog and articles are a different problem entirely - they're content, not UI strings. Those should eventually live in a headless CMS where content editors can manage translations without touching code.
Build Verification
Every translation change needs a build check. Missing translation keys, typos in key names, or import errors won't show until build time:
npm run buildWe run this after each batch of changes. The Next.js compiler catches missing translations and type errors in the translation calls.
What We Learned
Translation work is archaeology. You dig through layers of code, finding strings that were written quickly during initial development. The Swedish and English mix - sometimes in the same component - tells the story of a site built iteratively.
The systematic approach matters:
- Start with obvious UI - headings, buttons, labels
- Move to secondary UI - tooltips, placeholders, error messages
- Check accessibility - aria-labels, sr-only text, alt text
- Verify server-side code - emails, API responses
- Build and test each locale
Each layer reveals more. You think you're done, then find another batch of hardcoded strings in a component you forgot about.
The good news: once the infrastructure is in place, adding new languages is just translation work. The code doesn't need to change - you add a new JSON file and update the locale config.