Back to blog
Miloš Krstić5 min read

Building an Internationalized Angular Router

Building an Internationalized Angular Router
Click image to enlarge

What Problem Are We Solving?

Angular has evolved tremendously over the last few releases.

With Server-Side Rendering (SSR) now mature and first-class, Angular has become an excellent choice for building SEO-friendly applications.

For localization, we integrated ngx-translate to handle translations in a clean and familiar way. That part worked great.

However, SEO introduces an additional requirement.

We needed localized, crawlable URLs — meaning routes like:

  • /en
  • /sr

At first glance, this sounds simple.

But once you start wiring routing, localization, guards, initial navigation, and SSR together, things get interesting.

That’s where the Angular Router — and more importantly, Dependency Injection — comes into play.

Where This Article Is Going

In this article, we’ll explore:

  • why localization-aware routing matters for SEO
  • where the default Angular Router falls short
  • how to extend routing behavior using DI
  • how to keep the solution clean, testable, and SSR-friendly

Let’s dive in.

Why Localization-Aware Routing Matters for SEO

Search engines index URLs, not UI state.

If language is not reflected in the URL, crawlers can’t properly understand, rank, or target your content per locale.

Search engines assume:

  • /en/about
  • /sr/about

are two different documents intended for two different audiences.

If instead you do this:

/about   // language decided by JS, cookies, or headers

then from a crawler’s perspective:

  • there is only one page
  • language switching is opaque
  • SEO signals get mixed
  • indexing becomes unreliable

👉 Result: weaker rankings and incorrect language targeting.

Step 1: Initialize Language Early

The first thing we need is to initialize the language used by ngx-translate.

We do this using an App Initializer, ensuring the correct language is set before routing and rendering, which is especially important for SSR.

App Config:

import { ApplicationConfig, provideAppInitializer } from '@angular/core';
import { languageInitializer } from './core/language';

export const appConfig: ApplicationConfig = {
  providers: [
    provideTranslateService({
      lang: 'en',
      fallbackLang: 'en',
      loader: provideTranslateHttpLoader({
        prefix: '/i18n/',
        suffix: '.json'
      })
    }),
    provideAppInitializer(() => languageInitializer())
  ],
};

languageInitializer:

export type Language = 'sr' | 'en';

export const SUPPORTED_LANGUAGES = new InjectionToken<Language[]>(
  'SUPPORTED_LANGUAGES',
  {
    providedIn: 'root',
    factory: () => ['en', 'sr'] as const,
  }
);

export function languageInitializer() {
  const translate = inject(TranslateService);
  const platformLocation = inject(PlatformLocation);
  const supportedLanguages = inject(SUPPORTED_LANGUAGES);

  const path = platformLocation.pathname;
  const segments = path.split('/').filter(Boolean);
  const language = segments[0] || 'en';

  if (supportedLanguages.includes(language as Language)) {
    translate.use(language);
  }
}

This ensures the correct language is active based on the URL, before anything else happens.

Step 2: Language-Aware Routes

Now we define our routes with a dynamic :language segment.

import { Routes } from '@angular/router';
import { canMatchLanguageRoute } from './core/language';

export const routes: Routes = [
  {
    path: ':language',
    canMatch: [canMatchLanguageRoute],
    children: [
      {
        path: '',
        pathMatch: 'full',
        loadComponent: () =>
          import('./features/home/home').then(m => m.Home),
      },
      {
        path: 'about',
        loadComponent: () =>
          import('./features/about/about').then(m => m.About),
      },
    ],
  },
];

Guarding Unsupported Languages:

export function canMatchLanguageRoute(
  _route: Route,
  segments: UrlSegment[]
): boolean {
  const supportedLanguages = inject(SUPPORTED_LANGUAGES);
  const router = inject(Router);

  const language = segments[0]?.path as Language || 'en';

  if (!supportedLanguages.includes(language)) {
    router.navigateByUrl('/en');
    return false;
  }

  return true;
}

This prevents users and crawlers from accessing unsupported language routes while keeping the route definition clean.

Step 3: Extending the Router with DI

Now comes the interesting part.

We wanted every navigation — including router.navigateByUrl() and routerLink — to automatically include the current language without manually passing it around.

Instead of sprinkling language logic throughout the app, we extended Angular’s Router itself.

LanguageRouter

@Injectable()
class LanguageRouter extends Router {
  private readonly translate = inject(TranslateService);
  private readonly supportedLanguages = inject<Language[]>(SUPPORTED_LANGUAGES);

  override navigateByUrl(
    url: string | UrlTree,
    extras?: NavigationBehaviorOptions,
  ): Promise<boolean> {
    const currentLang = this.translate.getCurrentLang() || 'en';
    const urlString = typeof url === 'string' ? url : this.serializeUrl(url);

    const { path, query, fragment } = this.parseUrlParts(urlString);
    const finalPath = this.addLanguagePrefix(path, currentLang);
    const finalUrlString = this.reconstructUrl(finalPath, query, fragment);

    const finalUrl: string | UrlTree =
      typeof url === 'string' ? finalUrlString : this.parseUrl(finalUrlString);

    const currentUrl = this.url;
    const finalUrlSerialized =
      typeof finalUrl === 'string' ? finalUrl : this.serializeUrl(finalUrl);

    if (currentUrl === finalUrlSerialized) {
      return Promise.resolve(false);
    }

    return super.navigateByUrl(finalUrl, extras);
  }

  private isLanguagePath(segment: string): boolean {
    return this.supportedLanguages.includes(segment as Language);
  }

  private parseUrlParts(urlString: string): { path: string; query?: string; fragment?: string } {
    const questionMarkIndex = urlString.indexOf('?');
    const hashIndex = urlString.indexOf('#');

    let path: string;
    let query: string | undefined;
    let fragment: string | undefined;

    if (questionMarkIndex !== -1) {
      path = urlString.substring(0, questionMarkIndex);
      const afterQuestion = urlString.substring(questionMarkIndex + 1);
      if (hashIndex !== -1 && hashIndex > questionMarkIndex) {
        query = urlString.substring(questionMarkIndex + 1, hashIndex);
        fragment = urlString.substring(hashIndex + 1);
      } else {
        query = afterQuestion;
      }
    } else if (hashIndex !== -1) {
      path = urlString.substring(0, hashIndex);
      fragment = urlString.substring(hashIndex + 1);
    } else {
      path = urlString;
    }

    return { path, query, fragment };
  }

  private addLanguagePrefix(path: string, language: string): string {
    const pathSegments = path.split('/').filter((s) => s.length > 0);
    const firstSegment = pathSegments[0];
    const hasLanguagePrefix = firstSegment && this.isLanguagePath(firstSegment);

    if (hasLanguagePrefix) {
      if (firstSegment === language) {
        return path;
      }
      pathSegments[0] = language;
      return '/' + pathSegments.join('/');
    }

    const trimmedPath = path.startsWith('/') ? path.substring(1) : path;
    return `/${language}${trimmedPath ? '/' + trimmedPath : ''}`;
  }

  private reconstructUrl(path: string, query?: string, fragment?: string): string {
    let url = path;
    if (query) {
      url += '?' + query;
    }
    if (fragment) {
      url += '#' + fragment;
    }
    return url;
  }
}

Step 4: Override the Router via DI

Now we tell Angular to use our router instead of the default one.

export function provideLanguageRouter(): Provider[] {
  return [{ provide: Router, useClass: LanguageRouter }];
}

And register it after provideRouter:

export const appConfig: ApplicationConfig = {
  providers: [
    // ...
    provideAppInitializer(() => languageInitializer()),
    provideRouter(routes),
    provideClientHydration(withEventReplay()),
    provideLanguageRouter(), // must come after provideRouter
  ],
};

The Result

Now:

  • every routerLink
  • every navigateByUrl
  • every programmatic navigation

automatically respects the active language.

No repetitive code.

No manual prefixing.

No leaking localization logic into components.

Final Thoughts

This approach keeps:

  • routing declarative
  • localization explicit
  • SEO solid
  • SSR happy

And most importantly, it uses Angular’s Dependency Injection system the way it was meant to be used — extending behavior without fighting the framework.

If you’re building localized, SEO-friendly Angular apps, this pattern scales surprisingly well.

Happy routing! 🚀

Struggling with a similar frontend issue?

Get a Frontend Diagnosis
  • Identify performance bottlenecks
  • Spot architectural issues
  • Get actionable recommendations
Audit Frontend Architecture