Tutorial

Build a Multilingual Angular App with Localess

Learn how to build a multilingual Angular 19+ app using Localess — the Angular-native headless CMS with typed content models, signal-based components, and built-in i18n.

Learn how to build a multilingual Angular 19+ app using Localess — the Angular-native headless CMS with typed content models, signal-based components, and built-in i18n.

Angular has excellent built-in i18n tooling, but wiring it to a CMS that handles translation workflows, structured content, and locale routing usually means gluing together three or four separate tools. Localess is a headless CMS built in Angular and Firebase — which makes it the most naturally aligned content backend an Angular developer can pick. This tutorial walks through a complete integration: installing @localess/angular, configuring providers, fetching typed content, and rendering locale-aware standalone components with signals.

Prerequisites

  • Angular 20 or 21 app (standalone components, no NgModules required)
  • A running Localess instance — self-hosted or on Firebase. See the Localess GitHub repo for setup instructions.
  • Your Space ID and, for server-side calls, an API token — both found in Localess Space settings.
  • Node 20+, npm 10+

Install the Angular SDK

Localess ships a dedicated Angular package with two entry points: browser for client-side use (no token required) and server for SSR (token required, never exposed to the browser).

npm install @localess/angular@latest

Configure Providers

Angular applications use a functional ApplicationConfig in app.config.ts. Register the Localess browser provider there:

// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
import { provideLocalessBrowser } from '@localess/angular/browser';
import { LocalessService } from './shared/services/localess.service';
import { LocalessBrowserService } from './shared/services/localess-browser.service';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideClientHydration(
      withHttpTransferCacheOptions({
        filter: req => !req.url.startsWith("https://your-localess-instance.web.app"),
      }),
    ),
    provideHttpClient(withFetch()),
    provideLocalessBrowser({
      origin: 'https://your-localess-instance.web.app',
      spaceId: 'YOUR_SPACE_ID',
      enableSync: true,
    }),
    {
      provide: LocalessService,
      useClass: LocalessBrowserService,
    },
  ],
};

For SSR, register the server provider in app.config.server.ts. It merges with appConfig at build time:

// app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { provideLocalessServer } from '@localess/angular/server';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
import { LocalessService } from './shared/services/localess.service';
import { LocalessServerService } from './shared/services/localess-server.service';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(withRoutes(serverRoutes)),
    provideLocalessServer({
      origin: 'https://your-localess-instance.web.app',
      spaceId: 'YOUR_SPACE_ID',
      token: process.env['LOCALESS_TOKEN']!,
    }),
    {
      provide: LocalessService,
      useClass: LocalessServerService,
    },
  ],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

Keep the API token in an environment variable. It should never appear in browser-side code.

Define Typed Content Models

Localess is schema-first: you define content models in the CMS dashboard, then generate TypeScript types directly from your space's schemas using the Localess CLI. This keeps your types always in sync with the CMS — no manual mirroring required.

First, make sure you are logged in and the CLI knows your space:

npx localess login

Then generate types into your project:

npx localess types generate --path src/types/localess.ts

The command reads schemas live from the Localess API, so the generated types always reflect the current state of your space. Your API token needs Development Tools permission enabled in Localess Space settings.

Import the generated types wherever you reference content:

import type { HeroSection } from './src/types/localess';

The _id and _schema fields are included in every generated type — they enable the Visual Editor to highlight and select components on the page.

See the CLI types reference for all available options, including --prefix for namespacing type names.

Create the Abstract Content Service

The recommended pattern for SSR is an abstract service with two implementations — one for the server (fetches from the API and writes to TransferState) and one for the browser (reads from TransferState to avoid duplicate requests on hydration).

// shared/services/localess.service.ts
import { Injectable, makeStateKey } from '@angular/core';
import { Content, Links, ContentData } from '@localess/angular';
import { Observable } from 'rxjs';

@Injectable()
export abstract class LocalessService {
  LINKS_KEY = makeStateKey<Links>('ll:links');

  abstract getLinks(): Observable<Links>;
  abstract getContentBySlug<T extends ContentData = ContentData>(slug: string | string[], locale?: string): Observable<Content<T>>;
  abstract getContentById<T extends ContentData = ContentData>(id: string, locale?: string): Observable<Content<T>>;
}

Server implementation — fetches from the Localess API and stores results in TransferState:

// shared/services/localess-server.service.ts
import { inject, Injectable, makeStateKey } from '@angular/core';
import { TransferState } from '@angular/core';
import { Content, ContentData, Links } from '@localess/angular';
import { ServerContentService } from '@localess/angular/server';
import { Observable, tap } from 'rxjs';
import { LocalessService } from './localess.service';

@Injectable()
export class LocalessServerService extends LocalessService {
  private state = inject(TransferState);
  private contentService = inject(ServerContentService);

  getLinks(): Observable<Links> {
    return this.contentService.getLinks().pipe(
      tap(links => this.state.set(this.LINKS_KEY, links))
    );
  }

  getContentBySlug<T extends ContentData = ContentData>(slug: string | string[], locale?: string): Observable<Content<T>> {
    const normalizedSlug = Array.isArray(slug) ? slug.join('/') : slug;
    const key = makeStateKey<Content<T>>(`ll:content:slug:${normalizedSlug}`);
    return this.contentService.getContentBySlug<T>(normalizedSlug, { locale }).pipe(
      tap(content => this.state.set(key, content))
    );
  }

  getContentById<T extends ContentData = ContentData>(id: string, locale?: string): Observable<Content<T>> {
    const key = makeStateKey<Content<T>>(`ll:content:id:${id}`);
    return this.contentService.getContentById<T>(id, { locale }).pipe(
      tap(content => this.state.set(key, content))
    );
  }
}

Browser implementation — reads from TransferState so no duplicate network calls after hydration:

// shared/services/localess-browser.service.ts
import { inject, Injectable, makeStateKey } from '@angular/core';
import { TransferState } from '@angular/core';
import { Content, ContentData, Links } from '@localess/angular';
import { Observable, of } from 'rxjs';
import { LocalessService } from './localess.service';

@Injectable()
export class LocalessBrowserService extends LocalessService {
  private state = inject(TransferState);

  getLinks(): Observable<Links> {
    return of(this.state.get(this.LINKS_KEY, {}));
  }

  getContentBySlug<T extends ContentData = ContentData>(slug: string | string[], locale?: string): Observable<Content<T>> {
    const normalizedSlug = Array.isArray(slug) ? slug.join('/') : slug;
    const key = makeStateKey<Content<T>>(`ll:content:slug:${normalizedSlug}`);
    return of(this.state.get(key, {} as Content<T>));
  }

  getContentById<T extends ContentData = ContentData>(id: string, locale?: string): Observable<Content<T>> {
    const key = makeStateKey<Content<T>>(`ll:content:id:${id}`);
    return of(this.state.get(key, {} as Content<T>));
  }
}

The LocalessService token is provided in app.config.ts (browser) and app.config.server.ts (server) as shown in the provider setup above.

Fetch Content with a Route Resolver

Use the abstract LocalessService in route resolvers. Angular's router accepts Observables directly — no need to await firstValueFrom:

// app.routes.ts
import { inject } from '@angular/core';
import { ResolveFn, Routes } from '@angular/router';
import { Content } from '@localess/angular';
import { LocalessService } from './shared/services/localess.service';
import { HomeComponent } from './pages/home/home.component';

const resolveContent: ResolveFn<Content> = (route) => {
  const locale = route.queryParams['locale'] || undefined;
  return inject(LocalessService).getContentBySlug('home', locale);
};

export const routes: Routes = [
  {
    path: '',
    component: HomeComponent,
    resolve: { content: resolveContent },
  },
];

Standalone Component with Signal Inputs

Angular introduced signal inputs. The @localess/angular SDK provides SchemaWithSignalComponent<T> — a base class that wires up signal inputs and sets Visual Editor host attributes automatically.

// components/hero/hero.component.ts
import { Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { RouterLink } from '@angular/router';
import { SchemaWithSignalComponent, AssetPipe, LinkPipe } from '@localess/angular/browser';
import type { HeroSection } from '../../models/hero-section';

@Component({
  selector: 'app-hero',
  standalone: true,
  imports: [NgOptimizedImage, RouterLink, AssetPipe, LinkPipe],
  template: `
    <section class="hero">
      <h1>{{ data().title }}</h1>
      <p>{{ data().subtitle }}</p>
      <img
        [ngSrc]="data().backgroundImage | llAsset"
        width="1200"
        height="600"
        priority
        [alt]="data().title"
      />
      <a [routerLink]="links() | llLink: data().ctaLink">
        Get started
      </a>
    </section>
  `,
})
export class HeroComponent extends SchemaWithSignalComponent<HeroSection> {}

The data(), links(), and references() signal inputs are declared by SchemaWithSignalComponent. Pass them from the parent page:

// pages/home/home.component.ts
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
import { HeroComponent } from '../../components/hero/hero.component';

@Component({
  selector: 'app-home',
  standalone: true,
  imports: [HeroComponent],
  template: `
    @if (content()) {
      <app-hero
        [data]="content()!.data"
        [links]="content()!.links"
        [references]="content()!.references"
      />
    }
  `,
})
export class HomeComponent {
  private route = inject(ActivatedRoute);
  content = toSignal(this.route.data.pipe(map(d => d['content'])));
}

Locale Detection and Routing

Localess supports per-request locale scoping through the locale parameter on content fetch calls. A clean pattern for Angular is to drive the active locale from the URL — either a query param (?locale=fr) or a path prefix (/fr/...).

// shared/services/locale.service.ts
import { inject, Injectable, signal } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';

const SUPPORTED_LOCALES = ['en', 'fr', 'de', 'es'] as const;
type SupportedLocale = typeof SUPPORTED_LOCALES[number];

@Injectable({ providedIn: 'root' })
export class LocaleService {
  private router = inject(Router);
  private _locale = signal<SupportedLocale>('en');
  readonly locale = this._locale.asReadonly();

  constructor() {
    this.router.events
      .pipe(filter(e => e instanceof NavigationEnd))
      .subscribe(() => {
        const urlLocale = this.extractLocale(this.router.url);
        if (urlLocale) this._locale.set(urlLocale);
      });
  }

  private extractLocale(url: string): SupportedLocale | null {
    const segment = url.split('/')[1] as SupportedLocale;
    return SUPPORTED_LOCALES.includes(segment) ? segment : null;
  }

  setLocale(locale: SupportedLocale): void {
    this._locale.set(locale);
    this.router.navigate([`/${locale}`]);
  }
}

Pass locale() from LocaleService into your route resolvers to fetch the right translation from Localess on each navigation.

Why This Works Well for Angular

Localess is built in Angular. The SDK is designed around the same mental model: dependency injection, signals, standalone components, and SSR with TransferState. There is no impedance mismatch between the CMS data layer and your app code.

The two-entry-point design (browser vs server) maps cleanly onto Angular's SSR split. The browser entry requires no token, so you never need to worry about secrets leaking into the client bundle. The abstract LocalessService pattern ensures the router and components remain platform-agnostic — the Angular DI system swaps the correct implementation at runtime with no changes to your feature code.

TypeScript interfaces for content models give you autocomplete on content fields. The signal-based schema components integrate naturally with Angular's reactivity model — no additional state management library needed.

Source Code

The full source code for this tutorial is available in the GitHub repo: https://github.com/Lessify/localess-angular/tree/main/projects/angular-ssr

Next Steps

  • Enable the Visual Editor by setting enableSync: true in provideLocalessBrowser() — authors can then edit content live in the browser without deployments. Subscribe to window.localess.on(['input', 'change'], ...) (guarded with isPlatformBrowser) to receive real-time updates in your components.
  • Use the llRtToHtml and llSafeHtml pipes from @localess/angular/browser for rich text fields: <div [innerHTML]="data.body | llRtToHtml | llSafeHtml"></div>.
  • Explore the full Localess documentation and the npm package for asset optimization, reference resolution, and multi-space setups.

Ready to connect your Angular app to Localess? Star the repo and open a space — you can be fetching multilingual content in under ten minutes.