Back to Blog
5 min readBy Paul Joshi

Ditching next-intl: Building a Lightweight i18n System for Multi-App Next.js Monorepos

How we built a custom internationalization system that handles multiple applications and hundreds of translation files without the bloat of third-party libraries.

Next.jsArchitectureWeb Development

When we set out to internationalize our Next.js monorepo, we made a controversial decision. Skip the popular libraries. Build our own.

Six months later, with hundreds of pages translated and zero performance regressions, I'm convinced it was the right call.

The Problem with Off-the-Shelf Solutions

Why We Didn't Use next-intl

Don't get me wrong, next-intl is excellent for single-application deployments. But our monorepo threw curveballs.

First, the multi-app complexity. Application A needed one set of locales. Application B needed a completely different set. Application C only needed English. You get the idea.

Then there was the manual locale prefixing nightmare. Every single router.push() call required manual locale handling:

// Imagine this 500+ times across the codebase
const { lang } = useParams();
router.push(`/${lang}/dashboard`);

Worse still, next-intl wanted to dictate our entire folder structure. We'd have to restructure everything. Bundle bloat was another issue—we didn't need 90% of what these libraries provided. And monorepo namespace conflicts? Shared dictionary keys across apps would collide constantly.

The breaking point came when we realized we'd spend more time fighting the library than building features.

Our Custom Solution: Three Core Pieces

We built a minimal, data-driven system. Three components. That's it.

1. Registry-Based Dictionary Management

Instead of scattered translation imports, we centralized everything in typed registries. The structure is simple:

interface DictionaryConfig {
  key: string;
  imports: {
    [locale: string]: () => Promise<DictionaryData>;
  };
  app?: string;
}

Each registry entry maps a key to its translation files. Dynamic imports mean Next.js tree-shaking works perfectly. Each page only loads what it needs.

2. Automatic Locale Fallback Loader

Our getDictionary function handles fallback chains automatically. Dead simple to use:

const dict = await getDictionary(lang, "invoice-details", "app-name");

Behind the scenes, it tries locale variants, handles app namespacing to prevent key collisions, maintains backward compatibility, and gracefully degrades. Never throws. Always returns something.

3. The Router Wrapper: Our Secret Weapon

This solved 90% of developer pain.

A drop-in replacement for useRouter that automatically prefixes locales:

// Before
const { lang } = useParams();
router.push(`/${lang}/dashboard`); // fragile, error-prone

// After
router.push("/dashboard"); // locale is automatic

The wrapper checks if a locale already exists in the path. If not, it adds the current locale. If yes, it leaves it alone. Simple logic. Huge impact.

Architecture Deep Dive

Co-Located Dictionaries

We organize translations feature-first instead of locale-first. Translations live next to the code that uses them. This makes them easier to find and update. Code splitting boundaries happen naturally. Delete a feature? The translations go with it.

Central Registry Pattern

All dictionaries register themselves in app-specific registries. Each app has its own registry file. The central registry combines them all.

The loader auto-generates lookup keys with namespacing (locale-app:key) and maintains backward compatibility with legacy keys (locale-key). This prevents collisions across apps while keeping older code working.

Type Safety

We use TypeScript interfaces for dictionary data. Recursive types handle nested structures. Not as strict as next-intl's generated types, but good enough without the build complexity.

Performance Metrics

After six months in production, we're tracking hundreds of dictionary files across multiple apps. Pages using i18n? 200+. Registry entries keep growing as we add features.

Bundle increase per page stays minimal—only the needed dictionaries load. Developer mistakes from manual locale handling dropped dramatically. Time to add translations to a new feature? About 30 minutes.

Edge Cases We Solved

1. Language Switching

The router wrapper exposes a pushWithLocale method. It intelligently replaces the locale segment in the current path or adds one if it's missing. Users can switch languages without losing their place.

2. Legacy URL Object Support

We maintained backward compatibility with Next.js Pages Router-style URL objects. Old code keeps working. The wrapper converts objects to strings and applies locale prefixing automatically.

3. SEO & Metadata

Server-side dictionary loading works seamlessly in Next.js metadata functions. Load the dictionary, extract the localized meta tags, return them. Done.

Lessons Learned

What Went Right

The router wrapper alone was worth the effort. Developer ergonomics improved dramatically. Co-location scaled beautifully: hundreds of files, zero chaos. The minimal API surface meant onboarding new developers took minutes, not hours. Tree-shaking worked exactly as expected.

What We'd Change

We'd add Zod schemas for runtime validation and type generation from day one. Plural handling came later as a helper function—should've planned for it upfront. Dev-mode warnings for missing translations would've caught gaps faster.

When NOT to Use This Approach

Single application with standard i18n needs? Use next-intl. Complex translation requirements with plurals, gender, ICU formatting? Use i18next. Team unfamiliar with advanced TypeScript? Stick with documented libraries. Contentful or Sanity may be better for content heavy sites needing CMS integration.

The Bottom Line

We traded comprehensive features for laser focus on our needs. Fast. Type safe enough. Developer friendly. Monorepo native. Zero magic.

For multi-app monorepos with custom routing needs, building your own i18n system isn't just viable. It's often better than fighting opinionated libraries.


The concepts here adapt to any Next.js App Router project. You need three primitives: dynamic imports, locale detection from params, and wrapper patterns. That's it.

Questions? Do you have a better approach? Reach out to us.

Share this article

About the Author

P

Paul Joshi

Paul is a software engineer working on the Application Engineering team at Zero Pixels. The Application Engineering team builds client-facing and internal applications, leveraging AI and custom tooling to enhance efficiency and business impact.