📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~10 min de lecture

Lazy-loader la CSS de co-branding : comment j'ai retiré 2,5 Mo du bundle initial sur une app à 30 marques

Contexte : une app Angular white-label, 30 marques et des poussières. Chaque marque a sa charte complète, fonts, couleurs, radius, ombres, layouts un peu différents. L'utilisateur se connecte, son profil renvoie son brandId, et il ne verra jamais qu'une seule marque de toute sa session.

Sauf que le bundle initial chargeait les 30. Toutes les feuilles de style, concaténées dans le CSS global, envoyées à tout le monde, tout le temps. 6,5 Mo de bundle initial, dont une grosse part de CSS que 29/30 des users ne verront jamais.

On est descendus à ~4 Mo en ne chargeant que la feuille de la marque active. Aucune régression visuelle. Voici comment, et surtout quand ça vaut le coup (parce que dans plein de cas, tu ne devrais surtout pas faire ça).

Le problème : le co-branding en CSS pousse au "tout charger"

Le co-branding, ça commence toujours pareil. Deux marques, trois variables CSS qui changent, tu balances tout dans le styles.css global et tu swaps avec une classe sur le body. Propre, rapide, zéro latence.

Puis ça grossit. 5 marques. 12 marques. 30 marques. Chaque marque n'apporte plus 3 variables mais 40 Ko de règles : fonts en @font-face, composants restylés, backgrounds. Tu continues à tout mettre dans le global "parce qu'on a commencé comme ça", et un jour ton styles.css pèse plus lourd que ton app.

Le pire, c'est que ces marques sont mutuellement exclusives. Un user est sur la marque A ou la marque B, jamais les deux. Charger la CSS de B chez un user de A, c'est du poids mort pur.

/* styles.css version "on charge tout" */

/* brand-a : 38 Ko */
.brand-a { --accent: #e11d48; --radius: 12px; }
.brand-a .card { box-shadow: 0 8px 32px rgba(225, 29, 72, 0.2); }
@font-face { font-family: 'BrandA Sans'; src: url('/fonts/brand-a.woff2'); }
/* ... 400 lignes ... */

/* brand-b : 41 Ko, brand-c, ... et 27 autres marques */

Multiplie par 30, ajoute les @font-face de chaque marque : tu obtiens un bundle CSS qui bloque le premier rendu avec des règles dont 96 % sont inutiles pour l'utilisateur en face.

La ligne de décision : token-swap global ou lazy-load ?

Avant de sortir l'artillerie, pose-toi une question : est-ce que ta différence entre marques tient dans des variables CSS, ou est-ce que chaque marque a sa propre feuille lourde ?

Encart : si c'est juste un accent, garde-le global

Sur ce blog (EAK/AAK/RAK), on a trois "marques" produit. La surface est partagée (même gradient, même grille, même typo Geist). Seul l'accent change : jaune, corail, emerald. Le "thème" d'une marque, c'est littéralement quatre déclarations :

:root {
  --product-accent: var(--color-primary-yellow);
  --product-accent-secondary: var(--color-secondary-yellow);
  --product-accent-on-bg: var(--color-dark-blue);
  --product-accent-glow: 249, 237, 50; /* RGB pour rgba(...) inline */
}

:has([data-product='aak']) {
  --product-accent: var(--color-coral);
  --product-accent-secondary: var(--color-coral-deep);
  --product-accent-on-bg: var(--color-white);
  --product-accent-glow: 248, 113, 113;
}

:has([data-product='rak']) {
  --product-accent: var(--color-emerald);
  --product-accent-secondary: var(--color-emerald-deep);
  --product-accent-on-bg: var(--color-white);
  --product-accent-glow: 34, 197, 94;
}

Le scope se fait avec :has([data-product='X']). L'attribut n'est pas posé en JS : c'est un host binding statique sur le composant de page ([attr.data-product]='aak'), donc il fait partie du HTML rendu côté serveur, avant toute hydratation. Résultat : ça marche dès le SSR, zéro FOUC (Flash Of Unstyled Content, ce bref instant où la page s'affiche non stylée avant que la CSS arrive), zéro JS. Lazy-loader ça serait absurde : tu ajouterais une requête réseau et un risque de flash pour économiser 200 octets.

Règle : tant que la différence entre marques tient dans des variables CSS (couleurs, radius, quelques ombres), reste en token-swap global. C'est plus léger que n'importe quel lazy-load et ça survit au SSR sans effort.

Quand basculer sur le lazy-load

Tu bascules quand les deux conditions sont réunies :

  1. Chaque marque porte une feuille lourde et divergente (fonts dédiées, layouts, composants restylés), pas juste des tokens.
  2. Les marques sont mutuellement exclusives par session : un user ne voit qu'une marque, connue à un moment précis (ici, après le login).

C'est exactement le cas des 30 marques. Le token-swap n'y tient plus : 30 x 40 Ko de règles dans le global, c'est ingérable. Là, tu veux ne charger que la feuille de la marque active, à la demande.

La solution : une feuille par marque, chargée à la résolution du profil

Étape 1 : sortir chaque marque dans son propre fichier

Fini le méga styles.css. Chaque marque a son artefact, servi statiquement :

/themes/brand-a.9f3c1a.css
/themes/brand-b.2b77e0.css
...
/themes/brand-z.c04d51.css

Le styles.css global ne garde que le tronc commun : reset, layout, design tokens neutres. Les 30 chartes sortent du bundle initial.

Le content-hash dans le nom, c'est lui qui rend le fichier cacheable en immutable : modifier un thème change son hash, donc son URL, donc invalide le cache tout seul, sans purge ni ?v=. (Ce hash automatique suppose la voie A ci-dessous ; en voie B le nom est stable, on y revient.)

Étape 2 : la config build (le chaînon qu'on oublie)

Ces fichiers ne tombent pas du ciel. Deux voies. Voie A, assets statiques : tu poses les feuilles dans public/themes/, Angular les copie verbatim. Simple, mais elles ne passent pas par le pipeline de build (ni Tailwind compilé, ni autoprefixer, ni minification, ni hash auto) : à réserver à du CSS déjà final.

Voie B, bundles inject: false : tu déclares chaque thème dans la config du builder (angular.json, ou project.json en Nx, même schéma styles) :

"styles": [
  "src/styles.css",
  { "input": "src/themes/brand-a.css", "bundleName": "brand-a", "inject": false }
]

inject: false construit le bundle mais ne l'ajoute pas au <head> initial : il sort du chemin critique tout en passant par le pipeline complet. Tu le charges à la demande par son bundleName.

Le piège qui contredit l'étape 1 : un bundle inject: false sort avec un nom stable (brand-a.css, sans content-hash), pour être référencé par une URL fixe. Tu perds donc le cache-busting auto du hash. Soit voie A avec fichiers pré-hashés + manifeste { brandId: hashedHref } au build, soit voie B avec invalidation côté serveur/CDN (?v=deployId). Les snippets gardent brand-a.css pour la lisibilité.

Étape 3 : un service qui injecte le link et attend son chargement

Le coeur du truc. On crée un <link rel="stylesheet">, on attend son load avant de résoudre, et on swap l'ancienne feuille seulement une fois la nouvelle prête (sinon, flash de page non stylée entre les deux).

// brand-theme.ts
import { DOCUMENT } from '@angular/common';
import { inject, Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class BrandTheme {
  private readonly document = inject(DOCUMENT);
  private current: HTMLLinkElement | null = null;

  load(brandId: string): Promise<void> {
    const href = `/themes/${brandId}.css`;

    if (this.current?.getAttribute('href') === href) {
      return Promise.resolve();
    }

    return new Promise<void>((resolve, reject) => {
      const link = this.document.createElement('link');
      link.rel = 'stylesheet';
      link.href = href;

      link.onload = () => {
        // Swap seulement quand la nouvelle feuille est prete : pas de flash.
        this.current?.remove();
        this.current = link;
        resolve();
      };
      link.onerror = () => reject(new Error(`Theme ${brandId} failed to load`));

      this.document.head.appendChild(link);
    });
  }
}

Points qui comptent :

  • On attend onload. Si tu résous tout de suite, ton composant s'affiche avant que la CSS soit là. FOUC garanti.
  • On retire l'ancien link après avoir chargé le nouveau. L'inverse laisse une fenêtre sans style du tout.
  • inject(DOCUMENT) au lieu de document global : c'est ce qui rend le service SSR-safe (on y revient).

Cette version est volontairement minimale : copiée telle quelle en prod elle a trois trous que je bouche à l'étape 5 (appels concurrents, échec réseau, SSR). Ne t'arrête pas ici.

Étape 4 : bloquer le premier paint avec un resolver

La marque est connue après le login, via le profil. On veut que la feuille soit chargée avant que la première page de marque s'affiche. Le bon outil, c'est un resolver fonctionnel sur la route protégée : le router attend la promesse avant d'activer la route.

// brand-theme.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { BrandTheme } from './brand-theme';
import { UserProfile } from './user-profile';

export const brandThemeResolver: ResolveFn<boolean> = async () => {
  const profile = inject(UserProfile);
  const theme = inject(BrandTheme);

  // Le brandId vient du profil resolu au login (JWT, /me, peu importe).
  await theme.load(profile.brandId());
  return true;
};
// app.routes.ts
import { Routes } from '@angular/router';
import { brandThemeResolver } from './brand-theme.resolver';

export const routes: Routes = [
  {
    path: 'app',
    canActivate: [authGuard],
    resolve: { theme: brandThemeResolver },
    loadChildren: () => import('./workspace/workspace.routes'),
  },
];

Le resolver s'exécute juste après l'auth, avant le rendu de l'espace connecté. L'utilisateur ne voit jamais l'app sans son thème. Il re-tourne à chaque activation de la route, mais le service court-circuite si la feuille est déjà active (on le voit à l'étape 5), donc c'est idempotent.

Pourquoi pas provideAppInitializer ? Parce qu'il tourne une fois au bootstrap, avant le login, quand brandId est encore inconnu. Il n'est le bon outil que si la marque est connue dès le démarrage (sous-domaine marque.app.com, cookie). Marque résolue via le profil post-login = resolver, point.

Étape 5 : la version durcie (concurrence, échec, SSR, whitelist)

La version minimale a trois trous. En prod, tu les bouches tous :

  1. Appels concurrents. Deux navigations rapides déclenchent deux load(), deux <link>, deux onload qui se marchent dessus sur this.current. On mémorise la promesse en cours par href : un second appel identique rejoint la première au lieu d'injecter un doublon.
  2. Échec réseau. Sur onerror, on retire le <link> cassé (sinon il pollue le <head>) et on garde l'ancienne feuille en place, plutôt que de laisser l'user sans style.
  3. Comparaison d'URL fiable. On ne compare pas sur link.href (qui renvoie l'URL absolue résolue, https://host/themes/...) mais sur la valeur littérale qu'on a stockée. Sinon le déduplicateur casse silencieusement.

Plus une garde de sécurité : brandId vient du profil, donc d'une frontière réseau. On le valide contre une whitelist avant de le concaténer dans une URL (un brandId du genre ../secret ne doit pas fabriquer un chemin).

// brand-theme.ts
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';

const KNOWN_BRANDS = new Set(['brand-a', 'brand-b', 'brand-c' /* ...30 */]);

@Injectable({ providedIn: 'root' })
export class BrandTheme {
  private readonly document = inject(DOCUMENT);
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
  private currentHref: string | null = null;
  private currentLink: HTMLLinkElement | null = null;
  private readonly inFlight = new Map<string, Promise<void>>();

  load(brandId: string): Promise<void> {
    if (!KNOWN_BRANDS.has(brandId)) {
      return Promise.reject(new Error(`Unknown brand ${brandId}`));
    }

    const href = `/themes/${brandId}.css`;

    // Deja la feuille active, ou deja en cours de chargement : on ne re-injecte pas.
    if (this.currentHref === href) {
      return Promise.resolve();
    }
    const pending = this.inFlight.get(href);
    if (pending) {
      return pending;
    }

    const link = this.document.createElement('link');
    link.rel = 'stylesheet';
    link.href = href;
    this.document.head.appendChild(link);

    // Cote serveur : on ecrit le <link> dans le HTML et on rend la main.
    // platform-server ne declenche pas `load` sur un <link>, donc on n'attend pas.
    if (!this.isBrowser) {
      this.currentLink = link;
      this.currentHref = href;
      return Promise.resolve();
    }

    const promise = new Promise<void>((resolve, reject) => {
      link.onload = () => {
        this.currentLink?.remove(); // swap seulement quand la nouvelle est prete
        this.currentLink = link;
        this.currentHref = href;
        this.inFlight.delete(href);
        resolve();
      };
      link.onerror = () => {
        link.remove(); // on retire la feuille cassee, l'ancienne reste en place
        this.inFlight.delete(href);
        reject(new Error(`Theme ${brandId} failed to load`));
      };
    });

    this.inFlight.set(href, promise);
    return promise;
  }
}

Côté SSR : document.createElement fonctionne (DOM abstrait de platform-server), mais platform-server ne déclenche jamais l'évènement load d'un <link>. Si ton resolver await cet onload, tu bloques le rendu serveur pour rien. D'où le court-circuit !isBrowser.

Mais surtout : ce cas SSR ne vaut que si la coquille brandée est rendue côté serveur, donc si la marque est connue au premier flush HTML (sous-domaine public, marque devant le login). Dans le scénario de cet article, la marque vient du profil après authentification : l'espace connecté est quasi toujours du CSR pur. La branche serveur ne s'exécute alors jamais, la feuille est injectée au runtime par le resolver (étape 4), et le court-circuit !isBrowser ne sert que de garde-fou. Vérifie où ta marque est réellement connue au rendu serveur avant de compter dessus.

Le piège des fonts par marque

Un détail qui mord : si chaque thème embarque ses @font-face, ces fonts ne se téléchargent qu'après le parsing de la feuille de marque. Tu règles le FOUC CSS mais tu récoltes un FOIT/FOUT (Flash Of Invisible/Unstyled Text : texte invisible, puis flash de font quand la vraie arrive) au premier paint. La parade : précharger la font critique de la marque active en parallèle de la feuille.

const preload = this.document.createElement('link');
preload.rel = 'preload';
preload.as = 'font';
preload.type = 'font/woff2';
preload.href = `/themes/${brandId}.woff2`;
preload.crossOrigin = 'anonymous';
this.document.head.appendChild(preload);

Et l'anti-pattern à ne pas appliquer ici : le hack media="print" puis onload → media="all" qui rend le CSS non bloquant. Parfait pour du CSS accessoire, mais ici on veut bloquer le paint tant que le thème n'est pas là. Le rendre non bloquant réintroduit le FOUC qu'on cherche à tuer.

Before / after

Sur l'app à 30 marques :

  • Before : styles.css global embarquant les 30 chartes. Bundle initial ~6,5 Mo. Chaque user téléchargeait 30 thèmes pour en utiliser 1.
  • After : tronc commun global + une feuille par marque chargée au resolver. Bundle initial ~4 Mo. Chaque user télécharge exactement 1 thème, hashé et mis en cache pour les visites suivantes.

2,5 Mo retirés du chemin critique, zéro régression visuelle, et un bonus : ajouter une 31e marque n'alourdit plus le bundle de personne. Un fichier de plus, pas une ligne dans le global.

Récap actionnable

  1. Mesure d'abord la nature de ta différence entre marques. Variables CSS uniquement ? Reste en token-swap global avec :has() ou une classe sur le body. Ne lazy-load rien.
  2. Bascule sur le lazy-load seulement si chaque marque porte une feuille lourde et que les marques sont mutuellement exclusives par session.
  3. Un fichier CSS par marque (assets statiques hashés, ou bundle inject: false dans la config build). Le global ne garde que le tronc commun.
  4. Attends onload avant de swap l'ancienne feuille, sinon FOUC.
  5. Charge au resolver (ou provideAppInitializer) pour bloquer le paint tant que le thème n'est pas prêt.
  6. En SSR, n'attends pas onload côté serveur : écris le <link> dans le HTML et rends la main, le browser fait le reste.

Le co-branding en CSS, ce n'est pas "lazy-load ou pas". C'est "token-swap tant que c'est léger, lazy-load quand chaque marque devient un poids mort pour les 29 autres". La plupart des projets sont dans le premier cas. Ceux qui basculent au bon moment gagnent des mégaoctets. Sache dans quel cas tu es avant d'écrire une ligne.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.