DEV Community

AC0Hero
AC0Hero

Posted on

I Built an EU Law Tracker in 24 Languages. Then I Cut It Back to Four.

The first version of EU Inc Monitor had a simple internationalisation strategy: if a page existed in English, create it in every European language we supported.

That gave us 24 language routes, a satisfyingly large sitemap, and a serious technical problem.

Legal and regulatory content is not a normal localisation workload. A product landing page can survive a slightly stale translation. A page that explains a live legislative proposal cannot. The source document changes, committee positions diverge, dates move, and one misleading verb can turn a proposal into something that sounds like current law.

The system now has four active locales, only two of which are indexed. That retreat made the site smaller, more accurate, and easier for search engines to understand.

This is what changed in the Next.js architecture and why.

The original mistake: route count looked like coverage

The application uses Next.js 16 and next-intl. Every locale was a valid route prefix, so adding a language looked almost free:

export const routing = defineRouting({
  locales: activeLocales,
  defaultLocale: "en",
  localePrefix: "always",
});
Enter fullscreen mode Exit fullscreen mode

But route availability is not content quality.

With 24 locales, every new analysis created 24 public URLs. Search engines saw thousands of pages. Editors saw one English source and a translation backlog. When the European Commission published a correction or Parliament released a new amendment set, the English page could be updated immediately while the other 23 variants became progressively less reliable.

The most dangerous failure mode was not a broken page. It was a polished, indexable page that still described yesterday's legal position.

So we separated three concepts that had been incorrectly treated as one:

  1. A route can exist.
  2. A translation can be available to readers.
  3. A page can be approved for search indexing.

Those are different states.

The smaller locale policy

Today the policy is explicit in code:

export const indexedLocales = ["en", "de"] as const;
export const previewLocales = ["fr", "es"] as const;
export const activeLocales = [...indexedLocales, ...previewLocales] as const;
Enter fullscreen mode Exit fullscreen mode

English and German are the search core. French and Spanish remain available as reader previews, but they are noindex until they have passed native editorial review. The other 20 locales were retired instead of being left online as an unmaintained promise.

This is a business rule expressed as a small data structure. It drives routing, robots directives, canonical tags, hreflang output, and sitemap generation.

The key lesson was to avoid burying editorial policy inside page components. If five different files decide whether French should be indexed, they will eventually disagree.

Content availability is existence-driven

Not every insight exists in every active locale. Older articles may have more translations, while a new analysis may initially have only English and German.

The content loader therefore treats files on disk as the source of truth:

export function getAvailableInsightLocales(slug: string): string[] {
  const dir = resolve(contentRoot, slug);
  const present = new Set(
    readdirSync(dir)
      .filter((file) => file.endsWith(".md"))
      .map((file) => file.replace(/\.md$/, ""))
  );

  return routing.locales.filter((locale) => present.has(locale));
}
Enter fullscreen mode Exit fullscreen mode

That list is reused everywhere a language variant is exposed. A translation file is not merely copy. It is the capability that makes the corresponding route eligible to appear in navigation, metadata, and the insight sitemap.

We still allow a reader-facing fallback to English in a few contexts. But a fallback URL must never be advertised to search engines as a real translated page. A URL that renders English under a Spanish path is useful as a convenience for a person and harmful as an SEO signal.

Hreflang should describe reality, not ambition

Our largest technical cleanup came from hreflang.

The first implementation generated alternatives for every configured locale. Some of those URLs redirected. Others returned English fallback content. The framework also emitted an HTTP Link header using unprefixed URLs, while our HTML metadata pointed to prefixed routes such as /en/guide.

The result was two conflicting language clusters and hreflang entries that led to redirects.

We disabled automatic alternate links and made Next.js metadata the single owner:

export const routing = defineRouting({
  locales: activeLocales,
  defaultLocale: "en",
  localePrefix: "always",
  alternateLinks: false,
});
Enter fullscreen mode Exit fullscreen mode

Then we only emit alternatives that are both present and indexable:

const searchLocales = availableLocales.filter(isIndexedLocale);

const languages = Object.fromEntries(
  searchLocales.map((locale) => [
    locale,
    getLocalizedPath(locale, path),
  ])
);

return {
  canonical: getLocalizedPath(currentLocale, path),
  languages: {
    ...languages,
    "x-default": getLocalizedPath("en", path),
  },
};
Enter fullscreen mode Exit fullscreen mode

The rule is strict: an hreflang target should return 200, be indexable, contain the matching content, and link back into the same cluster. If any of those conditions is false, the target does not belong in the cluster.

This sounds obvious. It is also easy to violate when the list of languages comes from framework configuration rather than actual content.

Self-canonicals, including on preview pages

We considered pointing French and Spanish preview pages to the English canonical. That would have been wrong.

A translated page is not a duplicate of the English page merely because both express the same facts. Cross-language canonicals can cause the translated URL to disappear from the signals search engines use to understand language variants.

Our preview pages keep a self-canonical but use noindex, follow. They do not emit an incomplete hreflang cluster. When a locale passes editorial review, indexing and hreflang can be enabled without changing the URL.

That gives every page one clear state:

  • indexed and part of a reciprocal language cluster;
  • available as a self-canonical preview with noindex;
  • or retired and redirected to the final English destination.

We split the sitemap by intent

The site has stable reference pages, live legislative trackers, and editorial analyses. They do not change at the same rate and should not be treated as one undifferentiated URL dump.

We now generate a small core sitemap for hubs, methodology, sources, facts, and the live legislative timeline. Insights live in a separate sitemap built from an allowlist of articles that have met sourcing and editorial requirements.

The insight sitemap also reuses the locale policy:

for (const slug of indexableInsightSlugs) {
  const languages = Object.fromEntries(
    indexedLocales.map((locale) => [
      locale,
      getLocalizedUrl(locale, `/insights/${slug}`),
    ])
  );

  for (const locale of indexedLocales) {
    entries.push({
      url: getLocalizedUrl(locale, `/insights/${slug}`),
      alternates: {
        languages: {
          ...languages,
          "x-default": getLocalizedUrl("en", `/insights/${slug}`),
        },
      },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

An article being published is no longer enough to put it in the search sitemap. Publication and index eligibility are separate decisions.

The content model had to understand legal status

Internationalisation was only half of the problem. A regulation tracker needs a shared status model so every page agrees on what has happened.

We keep current status, recent developments, and upcoming legislative events in structured data. Pages read from that shared model instead of hand-writing the current position in every locale. Long-form analyses still live in Markdown, but volatile facts such as procedure stage and review date are centralised.

That reduces the number of sentences an editor must hunt down after a new Council document appears.

The public site also exposes its methodology, primary-source register, and correction process. For legal information, those are product features, not footer decoration.

What I would do from day one

If I were rebuilding the tracker, I would start with these constraints:

  1. Index one language first.
  2. Add a locale only when someone owns its ongoing editorial review.
  3. Represent route availability, translation availability, and index eligibility as separate states.
  4. Generate hreflang from real files and policy, never from the full framework locale list.
  5. Keep one metadata owner. Do not let middleware headers and page HTML describe different clusters.
  6. Test every hreflang target for 200, self-canonical, indexability, and reciprocity.
  7. Centralise volatile legal status instead of repeating it in prose.
  8. Retire weak locales cleanly rather than preserving them for vanity metrics.

The uncomfortable lesson is that international reach does not come from multiplying routes. It comes from maintaining trustworthy pages.

The site became more useful when it shrank from 24 active languages to four, and more honest when only two of those were allowed into search. That is not a failure of internationalisation. It is internationalisation finally respecting the cost of the content it carries.

Top comments (0)