Back to Journal

Deep-diving into Next.js internationalization

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:

Locale-Based RoutingUser Request/drogtestningMiddlewareDetect locale/sv/drogtestningSwedish/en/drogtestningEnglish/da/drogtestningDanish[locale]/page.tsxRenders content

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.

Server vs Client ComponentsServer Component (default)Can use: getTranslations()Cannot use: useTranslations()Client Component"use client" at topCan use: useTranslations()Components with interactive state, forms, or translations→ Need "use client" directive

The Hunt for Hardcoded Strings

This was the bulk of the work. Swedish text scattered across components like confetti. The process:

  1. Grep for Swedish characters - å, ä, ö are dead giveaways
  2. Check alt text on images - Often forgotten
  3. Form placeholders - Easy to miss
  4. Accessibility attributes - aria-label, sr-only text
  5. 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:

Remaining Untranslated ContentHIGH PRIORITY• Video play button aria-label• "Open main menu" sr-only• "Close menu" sr-only• Video placeholder textMEDIUM PRIORITY• Hero image alt text• Footer sr-only "Footer"• Language switcher titles• Footer column headingLOW PRIORITY (Needs CMS)• Product catalog data (40+ items with Swedish descriptions)• Blog articles (6 full articles in Swedish)• These belong in a database, not translation files

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 build

We 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:

  1. Start with obvious UI - headings, buttons, labels
  2. Move to secondary UI - tooltips, placeholders, error messages
  3. Check accessibility - aria-labels, sr-only text, alt text
  4. Verify server-side code - emails, API responses
  5. 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.