Saltar a contenido

Parte 8: Maps App

Una aplicación para dibujar mapas con Mapbox GL JS, una librería que pinta el mapa dentro de la página usando WebGL (el motor gráfico del navegador). Es una aplicación Angular 22 creada con ng new, con Tailwind CSS y daisyUI (tema cyberpunk), igual que la Parte 5: Pipes App y la Parte 7: Lifecycle Hooks. Es la primera que usa una librería de afuera que no es de Angular: no hay un componente listo, así que hay que conectarla a mano con el ciclo de vida del componente y con las señales. Tiene tres rutas (fullscreen, markers y houses); por ahora sólo está hecha fullscreen: un mapa a pantalla completa con un pin y un control de zoom.

Proyecto: 08-maps-app/. Sitio publicado: pendiente, la app todavía no está desplegada (falta crearla en Coolify). Mientras tanto se corre en local con npm start dentro de la carpeta (http://localhost:4200/), después de crear el .env con la clave de Mapbox (se explica en el Módulo MP1).

Esta página va creciendo: cada módulo nuevo se agrega al final.

Qué vas a aprender:

  • La clave de Mapbox. Por qué aquí no sirve el proxy de las partes anteriores, qué token usar y cómo llega al navegador sin subirlo a git (Módulo MP1).
  • El mapa en pantalla. Cargar el CSS de la librería, crear el mapa cuando el div ya existe, poner un pin y liberar el mapa al salir (Módulo MP2).
  • El zoom. Una sola señal que mantiene de acuerdo al deslizador y al mapa, con un effect y un evento de Mapbox (Módulo MP3).
  • El título de la página. Mostrar el título de la ruta en un navbar que vive fuera del router-outlet, con TitleStrategy (Módulo MP4).

Cada módulo sigue el mismo orden: el problema que resuelve, el código del repositorio, cómo funciona por dentro, errores comunes y preguntas de entrevista con su respuesta.

Esta página es una parte de la guía de estudio; allí están el mapa general y la ruta recomendada. Conviene haber visto antes la Parte 7: Lifecycle Hooks (ngAfterViewInit y effect) y el Módulo P5 sobre cómo proteger las claves.


Módulo MP1: La clave de Mapbox y el script set-envs

El problema

Para dibujar el mapa, Mapbox pide un token: un texto largo que dice de qué cuenta es la petición (y a quién cobrarle). Hay que resolver dos cosas a la vez: que el token no se suba a git y que llegue al navegador, porque Mapbox lo necesita allí.

En las partes anteriores la clave nunca llegaba al navegador: un proxy la agregaba en el servidor (se explica en el Módulo P5). Aquí no se puede: Mapbox GL JS pide los pedazos del mapa (los tiles) directo desde el navegador, con el token en cada petición. Si pasara por un proxy, habría que reenviar también el estilo, las fuentes y los íconos.

Dos tipos de token

Mapbox tiene dos tipos y se distinguen por el principio del texto:

Token Empieza con ¿Puede ir en el navegador?
Público pk. Sí, está hecho para eso
Secreto sk. Nunca

Como un token público acaba dentro del JavaScript que baja cualquiera que abra la página, no se protege escondiéndolo, sino limitándolo: en tu cuenta de Mapbox puedes restringir el token a ciertas direcciones (localhost:4200 y tu dominio) y poner alertas de uso. Así, si alguien lo copia, no le sirve en otro sitio.

El código del repositorio

El token vive en un archivo .env (que git ignora). La plantilla .env.template sólo dice qué variable existe, sin valor:

MAPBOX_KEY=your_mapbox_key_here

Un script de Node, set-envs.mjs, lee esa variable y genera los archivos de environments:

import { existsSync, writeFileSync, mkdirSync } from 'fs'

// Node loads .env by itself (no dotenv needed); in CI the variables come from the environment
if (existsSync('.env')) process.loadEnvFile()

const envDir = './src/environments'

const targetPath = `${envDir}/environment.ts`
const targetPathDev = `${envDir}/environment.development.ts`
const mapboxKey = process.env['MAPBOX_KEY']

if (!mapboxKey) {
  throw new Error('MAPBOX_KEY is not defined in the environment variables.')
}

const envFileContent = `export const environment = {
  mapboxKey: '${mapboxKey}'
}
`
// Create the environments directory if it doesn't exist and write the environment files
mkdirSync(envDir, { recursive: true })
writeFileSync(targetPath, envFileContent)
writeFileSync(targetPathDev, envFileContent)

El resultado, src/environments/environment.ts, es un objeto con el token, que el componente importa con el alias @environments/environment:

export const environment = {
  mapboxKey: '<el token>'
}

Como esos archivos contienen el token, no van en git. El .gitignore de la carpeta lo dice:

# Generated by scripts/set-envs.mjs from .env (they contain the Mapbox key)
/src/environments/

Para no tener que acordarse de correr el script, package.json lo engancha a los comandos de siempre:

"set-envs": "node ./scripts/set-envs.mjs",
"prestart": "npm run set-envs",
"start": "ng serve",
"prebuild": "npm run set-envs",
"build": "ng build",
"prewatch": "npm run set-envs",
"watch": "ng build --watch --configuration development",

Y el Dockerfile recibe la variable para que el build en Coolify también pueda generar los archivos:

# The build generates src/environments/ from this variable (see scripts/set-envs.mjs).
ARG MAPBOX_KEY
ENV MAPBOX_KEY=$MAPBOX_KEY
RUN npm run build

Cómo funciona por dentro

.env  ──►  scripts/set-envs.mjs  ──►  src/environments/environment.ts
(local)    (lo corre prestart          (no va en git)        │
            o prebuild)                                      ▼
                                          import { environment } from '@environments/environment'
  • pre… en npm. Si existe un script llamado prestart, npm lo ejecuta solo, antes de start. Por eso npm start y npm run build generan los archivos sin que lo pidas. Para generarlos sin arrancar nada: npm run set-envs.
  • process.loadEnvFile(). Node sabe leer un .env por sí mismo, así que no hace falta instalar dotenv. El if (existsSync('.env')) evita el error cuando no hay archivo: en Docker y en CI la variable llega directo del entorno.
  • El script falla a propósito si no hay MAPBOX_KEY: es mejor que se corte el build a publicar un mapa sin token.
  • En Coolify la variable MAPBOX_KEY se crea con la opción Available at Buildtime. En las partes 3, 4 y 6 es al revés (sin esa opción), porque allí la usa Nginx al arrancar; aquí la usa el build.

Esta app es la excepción a la regla de las otras: en 03, 04 y 06 los environment*.ts sí van en git, porque no llevan ninguna clave.

Errores comunes

  • Usar un token sk. en el navegador. Es un secreto y quedaría a la vista de todos. Usa siempre un pk..
  • Dejar el token público sin restricciones. Funciona, pero cualquiera que lo copie lo puede usar en su propio sitio y gastar tu cuota. Restríngelo por dirección.
  • Correr el script desde otra carpeta. Lee .env y escribe src/environments/ respecto a la carpeta donde estás. Ejecútalo dentro de 08-maps-app/, con npm run set-envs.
  • Escribir set-envs.js en vez de set-envs.mjs. El archivo usa import, así que es un módulo de ES y se llama .mjs; con otro nombre, Node no lo encuentra.
  • Olvidar Available at Buildtime en Coolify. Sin esa opción, el build no ve la variable y se corta con el mensaje del script.
  • Crear la variable después del primer despliegue. Hay que crearla antes: sin ella el build falla y queda publicada la versión anterior.

Resumen

  • El token de Mapbox va en el navegador, así que debe ser público (pk.) y estar restringido por dirección.
  • .env (ignorado) → set-envs.mjs → src/environments/ (ignorado).
  • prestart, prebuild y prewatch lo generan solos.
  • En Coolify, la variable se crea con Available at Buildtime.

Preguntas de entrevista

1. ¿Por qué esta app no usa un proxy para la clave, como las partes 3 y 4?

Respuesta:

Porque Mapbox GL JS pide los tiles directo desde el navegador con el token en cada petición, así que el token tiene que estar en el navegador de todos modos. Un proxy obligaría a reenviar también estilos, fuentes e íconos. Lo que protege aquí es usar un token público (pk.) restringido por dirección, no esconderlo.

2. ¿Qué diferencia hay entre un token pk. y uno sk., y cómo proteges el público?

Respuesta:

El pk. está pensado para ir en el navegador; el sk. da permisos de administración y nunca debe salir del servidor. Al pk. se le restringe la dirección desde la que puede usarse (localhost:4200 y el dominio de la app) y se configuran alertas de uso, así que copiarlo no le sirve a un tercero.

3. ¿Por qué src/environments/ está en el .gitignore aquí y en otras apps no?

Respuesta:

Porque aquí esos archivos contienen el token y en las otras no llevan ninguna clave (la clave va por el proxy). Lo que se sube es la receta para generarlos (set-envs.mjs y .env.template), no el resultado.

4. ¿Cómo llega el token al build en Coolify?

Respuesta:

La variable MAPBOX_KEY se crea en Coolify con Available at Buildtime. Coolify se la pasa al Dockerfile como argumento (ARG), el Dockerfile la convierte en variable de entorno (ENV) y prebuild ejecuta set-envs.mjs, que genera src/environments/ antes de ng build.

5. ¿Para qué sirve el prefijo pre en prestart y prebuild?

Respuesta:

npm ejecuta automáticamente el script preX justo antes del script X. Así npm start y npm run build generan los archivos de environments sin que nadie tenga que recordarlo.

Módulo MP2: El mapa en pantalla

El problema

Mapbox no se puede usar como un componente de Angular: es una librería que necesita un elemento real de la página donde dibujar. Eso trae tres preguntas: ¿cuándo existe ese elemento?, ¿cómo lo encuentro? y ¿quién limpia el mapa cuando el usuario se va?

El código del repositorio

Primero, el CSS de la librería. Sin él, los controles y los pines se ven rotos. Se carga en angular.json, antes de los estilos de la app:

"styles": [
  "node_modules/mapbox-gl/dist/mapbox-gl.css",
  "src/tailwind.css",
  "src/styles.scss"
]

La plantilla de fullscreen-map-page.component.html tiene un div vacío (el control de zoom se explica en el Módulo MP3):

<!-- Mapbox draws the map inside this div; it needs a height, or the map is invisible -->
<div #map class="h-screen w-full"></div>

El .scss del componente le da el tamaño: todo el ancho y el alto de la ventana menos el navbar de 64 px:

div {
  width: 100%;
  // Subtract the height of the header (64px) from the viewport height
  height: calc(99vh - #{$controls-header-height});
}

Y el componente, fullscreen-map-page.component.ts (aquí se omite el zoom, que es del módulo siguiente):

export class FullscreenMapPageComponent implements AfterViewInit {
  // The <div #map> where Mapbox draws the map. It must exist in the DOM before the map is created
  private readonly mapContainer = viewChild.required<ElementRef<HTMLDivElement>>('map')
  private readonly destroyRef = inject(DestroyRef)
  // ... las señales del zoom

  // The view is ready here, so the map container already exists
  ngAfterViewInit(): void {
    // The public token (pk.) generated by scripts/set-envs.mjs from MAPBOX_KEY
    mapboxgl.accessToken = environment.mapboxKey
    const mapContainer = this.mapContainer().nativeElement

    if (!mapContainer) return

    const map = new mapboxgl.Map({
      container: mapContainer,
      style: MAP_CONFIG.style,
      center: MAP_CONFIG.center, // [lng, lat]
      zoom: this.zoomLevel(),
    })
    // The pin: it is only visible once it is added to the map
    const marker = new mapboxgl.Marker({
      color: MARKER_CONFIG.color,
      draggable: MARKER_CONFIG.draggable,
    })
      .setLngLat(MARKER_CONFIG.position)
      .addTo(map)

    this.mapListeners(map)
    // Free the map (and its WebGL context) when the user leaves the page
    this.destroyRef.onDestroy(() => map.remove())
  }
}

Los valores iniciales no están en el componente, sino en map.constants.ts, con sus tipos en mapboxCustomOptions.ts:

// A point on the map: [longitude, latitude]
export type Coordinates = [number, number]

// Initial map: Mapbox streets style, centered on COORDINATES; zoom goes from 0 (the world) to 22 (a building)
export const MAP_CONFIG: MapOptions = {
  style: 'mapbox://styles/mapbox/streets-v12',
  center: COORDINATES,
  zoom: 15,
}

export const MARKER_CONFIG: MarkerOptions = {
  color: 'red',
  position: COORDINATES,
  draggable: false,
}

Cómo funciona por dentro

Piensa en una televisión: Mapbox es la señal y el div es la pantalla. Sin pantalla no hay dónde dibujar, y una pantalla sin alto no se ve. La comparación se queda corta en algo: la «pantalla» la crea Angular, no tú, así que tienes que esperar a que exista.

Pieza Para qué sirve
#map y viewChild.required Angular te entrega el div de la plantilla. .required avisa si no existe, y .nativeElement es el elemento real del navegador
ngAfterViewInit Es el momento en que la vista ya está dibujada y el div existe (el orden de los hooks está en el Módulo LH1). En el constructor todavía no
mapboxgl.accessToken Le dice a la librería con qué token pedir los datos
new mapboxgl.Map({...}) Crea el mapa dentro de container, con un estilo, un centro y un zoom
new mapboxgl.Marker(...) Crea el pin; no se ve hasta que le haces .addTo(map)
map.remove() Destruye el mapa y libera su memoria gráfica

destroyRef.onDestroy(...) registra una función que Angular llama justo antes de destruir el componente. Sin ella, cada vez que el usuario sale de la página y vuelve quedaría un mapa más en memoria.

El orden de las coordenadas es [longitud, latitud], al revés que en Google Maps. La longitud va primero.

Errores comunes

  • No cargar el CSS de Mapbox. El mapa se dibuja, pero con los controles desordenados.
  • Un contenedor sin alto. El mapa existe pero mide cero y no se ve. Por eso la plantilla tiene h-screen y el .scss fija el alto.
  • Poner la latitud primero. Un pin en Los Ángeles aparece en medio del océano: Mapbox espera [lng, lat].
  • No llamar a map.remove(). El navegador limita cuántos contextos gráficos puede tener abiertos y la página termina fallando después de navegar varias veces.
  • Que el mapa quede vacío al guardar un estilo. Con ng serve, Angular parchea la página en caliente (HMR) sin recargarla; el mapa se crea una sola vez, al cargar, y a veces no se vuelve a crear. Se nota porque el div sigue ahí pero sin el <canvas> adentro. Se arregla arrancando con npx ng serve --no-hmr: cada cambio recarga la página completa y el mapa se crea siempre.
  • Cada recarga cuenta como una carga de mapa. Mapbox cobra por cada mapa que se crea y el plan gratuito tiene un tope mensual (el número vigente está en tu cuenta). Para desarrollar sobra, pero conviene poner una alerta de uso.

Resumen

  • Un div con alto + viewChild + ngAfterViewInit + new mapboxgl.Map.
  • El CSS de la librería va en angular.json.
  • Las coordenadas son [lng, lat].
  • map.remove() al destruir el componente.

Preguntas de entrevista

1. ¿Por qué el mapa se crea en ngAfterViewInit y no en el constructor o en ngOnInit?

Respuesta:

Porque Mapbox necesita el div ya dibujado en la página. En el constructor y en ngOnInit la vista todavía no existe, así que viewChild no tiene el elemento. ngAfterViewInit es el primer momento en que está garantizado.

2. ¿Qué te devuelve viewChild.required y por qué hace falta .nativeElement?

Respuesta:

Devuelve una señal con un ElementRef, que es el envoltorio que Angular pone alrededor del elemento. .nativeElement es el elemento real del navegador (un HTMLDivElement), que es lo que Mapbox necesita como container. .required lanza un error si el elemento no existe, en vez de devolver undefined.

3. ¿Qué pasa si no llamas a map.remove() al salir de la página?

Respuesta:

El mapa sigue vivo en memoria con su contexto gráfico (WebGL). Cada vez que se entra a la página se crea otro y el navegador acaba por no poder abrir más: fugas de memoria y mapas en blanco. DestroyRef.onDestroy es el lugar para limpiarlo.

4. ¿En qué orden se pasan las coordenadas a Mapbox?

Respuesta:

[longitud, latitud]: longitud primero, al revés que Google Maps. En el repositorio el tipo Coordinates = [number, number] lo deja anotado para no confundirse.

5. ¿Qué es el HMR y por qué puede dejar el mapa vacío?

Respuesta:

HMR (Hot Module Replacement) es la función de ng serve que, al guardar un archivo, cambia sólo esa pieza en la página sin recargarla. El mapa se crea una sola vez al cargar la página, así que, si el componente se parchea sin recargar, a veces no se vuelve a crear. Es un asunto del desarrollo y no afecta al sitio publicado; con ng serve --no-hmr desaparece.

Módulo MP3: El zoom: una señal, un effect y los eventos del mapa

El problema

El zoom lo puede cambiar el usuario de dos maneras: moviendo el deslizador de la pantalla o haciendo zoom en el mapa (rueda del ratón, botones o doble clic). Los dos tienen que mostrar el mismo número. Si cada uno guardara su propio valor, acabarían contradiciéndose.

La solución es tener un solo valor, una señal, y dos caminos:

deslizador ──► zoomLevel (señal) ──► effect ──► map.zoomTo(...)    el mapa se mueve
mapa (zoomend) ──► zoomLevel (señal) ──► el deslizador y el número se actualizan

El código del repositorio

En fullscreen-map-page.component.ts, las dos señales y el effect (de deslizador a mapa):

// The map lives in a signal so the effect below can react when it is ready (null until then)
map = signal<mapboxgl.Map | null>(null)
// Single source of truth for the zoom: the slider and the map both read and write it
zoomLevel = signal(MAP_CONFIG.zoom)
// Slider -> map: every time zoomLevel changes, move the map to that zoom
zoomEffect = effect(() => {
  if (!this.map()) return

  // zoomTo is a Mapbox GL JS method to smoothly animate the map to a specified zoom level
  this.map()!.zoomTo(this.zoomLevel())
})

El método que recibe el valor del deslizador y el que escucha al mapa (de mapa a deslizador):

// Called by the slider: saves the new value (an <input> gives a string, hence Number)
zoomLevelChange(value?: number) {
  if (value !== undefined) {
    this.zoomLevel.set(Number(value))
  }
  return this.zoomLevel()
}

mapListeners(map: mapboxgl.Map) {
  // Map -> slider: when the user zooms with the wheel or the +/- buttons, update the signal.
  // If the new value equals the current one the signal does not notify, so there is no loop
  map.on('zoomend', (event) => {
    const newZoomLevel = map.getZoom()
    this.zoomLevel.set(newZoomLevel)
  })

  // Shows the real reason in the console when the map does not load
  // (invalid token, blocked request, style not found...)
  map.on('error', (event) => console.error('Mapbox error:', event.error))

  // Publish the map so the effect can start using it
  this.map.set(map)
}

Y el deslizador en la plantilla:

<!-- #txtZoom is a reference to this input, so (input) can read its current value -->
<input
  type="range"
  min="1"
  max="19"
  [value]="zoomLevel()"
  (input)="zoomLevelChange(+txtZoom.value)"
  class="range range-primary"
  #txtZoom
/>

El número que se ve junto al título sale de {{ zoomLevel() | number: '1.0-0' }}: el pipe number lo muestra sin decimales (con la rueda el zoom puede valer 15,37).

Cómo funciona por dentro

¿Por qué el mapa va dentro de una señal? Porque un effect sólo se entera de lo que cambia si lee una señal. El mapa no existe hasta ngAfterViewInit, así que empieza en null; cuando mapListeners hace this.map.set(map), el effect se vuelve a ejecutar y ya puede usarlo. (Los effect se explican en el Módulo LH1.)

Qué lee el effect cada vez. La primera vez, this.map() es null y el effect sale con el return: sólo se suscribió a map. Cuando el mapa llega, corre otra vez, lee también zoomLevel() y desde entonces reacciona a los dos.

Por qué no hay un bucle infinito. Mover el deslizador cambia zoomLevel, el effect hace zoomTo, el mapa termina de moverse y dispara zoomend, que vuelve a escribir zoomLevel. Pero ese valor ya es el mismo, y una señal no avisa cuando se le escribe lo que ya tiene. Ahí se corta.

zoomend y no zoom. El evento zoom se dispara muchas veces mientras el mapa se anima; zoomend sólo cuando el zoom terminó, sea por la rueda, por los botones o por nuestro propio zoomTo.

El deslizador. [value]="zoomLevel()" lleva la señal hacia el input, y (input) lleva el valor del input hacia la señal. #txtZoom es una referencia a ese elemento para leer txtZoom.value; como un input entrega texto, se convierte a número con + y con Number(...).

Errores comunes

  • Escuchar zoom en vez de zoomend. Actualizas la señal decenas de veces por segundo y el effect empieza a pelearse con la animación.
  • Guardar el mapa en una propiedad normal. El effect no se entera cuando llega y el deslizador no mueve el mapa.
  • Olvidar convertir a número. El input entrega "10" (texto) y zoomTo espera un número.
  • Quitar el if (!this.map()) return. Antes de que exista el mapa el effect intentaría usar null y fallaría. El ! de this.map()! es seguro sólo por ese if.

Resumen

  • Una señal (zoomLevel) es la única fuente de verdad del zoom.
  • Deslizador → mapa: effect + zoomTo.
  • Mapa → deslizador: el evento zoomend + zoomLevel.set(...).
  • El mapa vive en una señal para que el effect se entere de cuándo llega.

Preguntas de entrevista

1. ¿Por qué el mapa se guarda en una señal y no en una propiedad normal?

Respuesta:

Porque el effect sólo reacciona a las señales que lee. El mapa se crea después (en ngAfterViewInit); si estuviera en una propiedad normal, el effect nunca se enteraría de que ya existe. Con una señal que empieza en null, el effect vuelve a correr cuando se publica el mapa.

2. ¿Por qué no se genera un bucle entre el effect y el evento zoomend?

Respuesta:

Porque una señal no notifica cuando se le escribe un valor igual al actual. El effect mueve el mapa a ese zoom, zoomend escribe ese mismo valor y la señal no cambia, así que el effect no vuelve a ejecutarse.

3. ¿Por qué se escucha zoomend y no zoom?

Respuesta:

zoom se dispara en cada fotograma de la animación; zoomend, una sola vez al terminar. Escuchar zoom actualizaría la señal muchas veces por segundo y haría que el effect interrumpiera la animación con nuevos zoomTo.

4. ¿Qué lee el effect la primera vez que corre?

Respuesta:

Sólo this.map(). Como todavía es null, sale con el return antes de llegar a this.zoomLevel(), así que en esa primera ejecución depende únicamente del mapa. Cuando se publica el mapa, corre de nuevo, lee las dos señales y desde entonces reacciona a ambas.

Módulo MP4: El título de la página con TitleStrategy

El problema

El navbar muestra el título de la página donde estás («Fullscreen Map», «Markers Map»…). Cada ruta ya lo trae en app.routes.ts:

{
  path: 'fullscreen',
  component: FullscreenMapPageComponent,
  title: 'Fullscreen Map',
},

La pregunta es cómo leerlo desde el navbar. En la Parte 5 un componente de página lo leía con this.route.snapshot.title. En el navbar eso no funciona.

Piensa en un edificio. Las páginas son las salas y el <router-outlet> es la puerta por donde se cambia de sala. El navbar está en el vestíbulo, fuera de las salas. El ActivatedRoute que se inyecta en un componente es el de la sala donde vive; el navbar, que vive en el vestíbulo, recibe la ruta raíz, que no tiene título. Además, snapshot es una foto tomada una vez, y el navbar no se vuelve a crear al navegar, así que la foto quedaría vieja.

Tres maneras de resolverlo

Manera Cómo Cuándo conviene
snapshot.title Una foto de la ruta al crear el componente Un componente de página, que se crea al entrar a la ruta
Escuchar los eventos del router router.events + NavigationEnd, y leer la ruta activa Un componente fijo fuera del outlet. Funciona, pero hay que escribir el recorrido a mano
TitleStrategy El router le avisa a una clase propia en cada navegación Lo más limpio: el título ya viene calculado y se comparte con quien lo necesite

Las dos primeras quedan comentadas en navbar.component.ts como referencia (la primera, que busca el título comparando la URL con la lista de rutas, es frágil: falla con parámetros y con rutas hijas). La que se usa es la tercera.

El código del repositorio

app-title-strategy.service.ts es una clase que extiende TitleStrategy:

@Service()
export class AppTitleStrategy extends TitleStrategy {
  // Angular service that writes the browser tab title (document.title).
  private readonly title = inject(Title)
  // Title of the current page ("Home"), so other components (the navbar) can read it.
  readonly pageTitle = signal(DEFAULT_TITLE)

  // The router calls this after every navigation.
  override updateTitle(snapshot: RouterStateSnapshot) {
    // buildTitle returns the `title` of the active route, or undefined if it has none.
    const routeTitle = this.buildTitle(snapshot) ?? DEFAULT_TITLE
    // The navbar shows only the page title; the browser tab adds the app name in front
    const fullTitle = `${DEFAULT_TITLE} - ${routeTitle}`
    this.pageTitle.set(routeTitle)
    this.title.setTitle(fullTitle)
  }
}

Se le avisa al router que use esta clase en app.config.ts:

// TitleStrategy is the router's base class that decides the document title on every navigation.
// Here we tell the router to use our AppTitleStrategy instead of the default one,
// so the same instance (and its pageTitle signal) is shared with the rest of the app.
{ provide: TitleStrategy, useExisting: AppTitleStrategy },

Y el navbar sólo lee la señal:

// 3. Title strategy (the one in use): the router already computes the title on each navigation,
//    so we just read the signal that AppTitleStrategy keeps up to date. No router events needed.
pageTitle = inject(AppTitleStrategy).pageTitle
<a class="btn btn-ghost text-xl">{{ pageTitle() }}</a>

Cómo funciona por dentro

  • TitleStrategy es una clase abstracta del router: una plantilla base que no se usa directamente. Tú creas una clase que la extiende y completas lo que falta, updateTitle.
  • Angular ya trae una por defecto (DefaultTitleStrategy) que pone el title de la ruta en la pestaña del navegador. Por eso tus títulos ya aparecían en la pestaña sin hacer nada.
  • updateTitle(snapshot) es el método que el router llama después de cada navegación. Aquí hace dos cosas: guarda el título de la ruta en la señal pageTitle (para el navbar) y escribe «Angular Maps - Título» en la pestaña.
  • buildTitle(snapshot) ya viene en la clase base: recorre las rutas hasta la activa y devuelve su title, también cuando es un título calculado. Si la ruta no tiene, devuelve undefined, y por eso el ?? DEFAULT_TITLE.
  • Title es un servicio de Angular que escribe la pestaña del navegador (document.title).
  • { provide: TitleStrategy, useExisting: AppTitleStrategy } dice: «cuando el router pida una TitleStrategy, dale la instancia de AppTitleStrategy». useExisting reutiliza la misma instancia que se inyecta en el navbar, así que el router y el navbar comparten la misma señal.
  • @Service() (Angular 22) declara una clase como servicio y Angular lo provee solo, sin tener que registrarlo.

Errores comunes

  • Usar this.route.snapshot.title en el navbar. Recibe la ruta raíz y no tiene título.
  • Buscar el título comparando la URL con la lista de rutas. Se rompe con parámetros de ruta (/casa/5), con parámetros de consulta (?x=1) y con rutas hijas.
  • Crear la clase y olvidar el provide: TitleStrategy. El router sigue usando la de por defecto, updateTitle nunca se llama y la señal se queda con DEFAULT_TITLE.
  • Una ruta sin title. No falla: buildTitle devuelve undefined y se usa DEFAULT_TITLE.

Resumen

  • El navbar vive fuera del outlet: snapshot.title no le sirve.
  • Una clase que extiende TitleStrategy recibe el aviso en cada navegación.
  • Guarda el título en una señal que el navbar lee, y escribe la pestaña con un prefijo.
  • Se registra con { provide: TitleStrategy, useExisting: ... }.

Preguntas de entrevista

1. ¿Por qué this.route.snapshot.title no sirve en un navbar que está fuera del router-outlet?

Respuesta:

Porque el ActivatedRoute que se inyecta es el de la ruta donde vive el componente, y el navbar vive fuera del outlet, así que recibe la ruta raíz, que no tiene título. Además snapshot es una foto tomada una sola vez, y el navbar no se vuelve a crear al navegar.

2. ¿Cuándo sí conviene snapshot.title?

Respuesta:

En un componente de página, que se crea al entrar a la ruta y se destruye al salir: la foto es correcta y no hay que escuchar nada. Si la ruta tiene parámetros y el componente puede reutilizarse sin recrearse (de /casa/1 a /casa/2), la foto queda vieja y hace falta un observable.

3. ¿Qué es TitleStrategy y qué hace buildTitle?

Respuesta:

Es la clase base con la que el router decide el título del documento tras cada navegación. Tú la extiendes e implementas updateTitle(snapshot). buildTitle viene hecho en la clase base y devuelve el título de la ruta activa, o undefined si no tiene.

4. ¿Para qué sirve useExisting en el provide?

Respuesta:

Para que el router y el resto de la app usen la misma instancia de AppTitleStrategy. Si se usara useClass, el router crearía la suya y el navbar leería la señal de otra distinta, que nunca se actualizaría.

5. ¿Qué ventaja tiene TitleStrategy sobre escuchar NavigationEnd?

Respuesta:

El router ya calculó el título, así que no hay que filtrar eventos ni recorrer las rutas a mano, y funciona con rutas hijas y con títulos calculados. Además deja un solo lugar que decide el título de la pestaña y del navbar.