Déployer un SPA Angular prerendu (SSG) sur Azure Static Web Apps
<app-root> et un bundle à télécharger ; le contenu n'apparaît qu'une fois le JavaScript exécuté. Pour un crawler qui n'exécute pas ce JS, ou qui l'exécute avec un budget serré, la page est blanche au moment où il décide quoi indexer.
angular.json jusqu'au workflow GitHub qui déploie sur Azure Static Web Apps.
Le prérendu natif, sans serveur Node
@angular/ssr . Le levier tient dans la cible de build.
outputMode: "static" demande au builder de prérendre chaque route déclarée et de n'émettre que des fichiers : main.server.ts sert au rendu à la compilation, pas à l'exécution. La sortie atterrit dans dist/super-dev-portfolio/browser/ , un dossier de HTML, CSS et JS statiques. Rien à faire tourner côté serveur, ce qu'attend exactement un hébergeur de fichiers comme Azure SWA.
provideClientHydration(withEventReplay()) réamorce l'application sur le HTML déjà présent, sans re-rendre le DOM. Le premier affichage vient du fichier, l'interactivité vient du bundle une fois chargé.
Un arbre statique par langue, jamais de param :lang
:lang/fr , /en , /es , /de ). La tentation naturelle est une route parente paramétrée :lang avec un redirectTo . Elle casse le prérendu natif : le <router-outlet> ressort vide dans le HTML généré.
LANGS.map construit une route parente par langue dont le path est une chaîne littérale ( fr , en …), pas un paramètre ; deux routes de repli complètent l'ensemble, un '' qui redirige vers la langue par défaut et un ** attrape-tout vers la même cible.
LANGS : les arbres, le sitemap et les hreflang se déduisent tous de cette liste.
langResolver pose la locale avant le rendu du composant, pour que le prérendu et le premier paint partent sur la bonne langue.
Énumérer les slugs à figer
:slug . Le prérendu ne peut pas deviner ces valeurs, il faut les lui fournir. getPrerenderParams renvoie la liste, lue directement dans le contenu FR.
flatMap sur LANGS produit un jeu de routes par langue ; pour chacune, chaque slug d'article devient une page. Le catch-all ** en RenderMode.Prerender couvre les pages statiques restantes ( /fr , /en/about …). Tout est prérendu, sans exception : c'est la condition pour qu' outputMode: static n'ait aucun serveur à laisser tourner.
:slug via input() ( withComponentInputBinding ), donc la même page se réhydrate proprement côté client.
La garde qui refuse un build muet
webServer de Playwright est ng serve , qui ne prérend rien : un test « JS coupé » y verrait la coquille SPA, jamais le HTML figé. La garantie « découvrable sans JS » est donc une assertion sur la sortie de build, pas un test de navigateur.
check-prerender.mjs comble ce trou. Pour chaque slug d'article et chaque langue, il lit l' index.html produit et pose quatre exigences. Le JSON-LD doit contenir "@type":"BlogPosting" et la datePublished exacte tirée du contenu. La carte sociale og/<slug>.<lang>.jpg doit exister dans le build et être référencée par la page. La région article-detail__body doit porter au moins 200 caractères de texte une fois les balises retirées, preuve que le Markdown a été rendu. Enfin, aucun ** ne doit subsister dans la prose, ce qui trahirait du Markdown non converti ; le contrôle exclut d'abord les blocs de code, où ** est un opérateur ou un glob légitime.
process.exit(1) , et le build casse. Ce script est la dernière étape de build:ssg , dont l'ordre compte : gen:read-times recalcule les temps de lecture depuis le vrai nombre de mots avant la compilation, ng build --configuration production prérend, gen:seo écrit sitemap, robots et llms dans le dossier livré, et check-prerender.mjs valide le tout. C'est cette chaîne exacte que lance le workflow de déploiement.
Le SEO figé dans le <head>
<head><head> au moment où la route est prête. Le SeoService écrit donc titre, description, Open Graph, canonical , hreflang et JSON-LD de façon idempotente : chaque balise est posée ou remplacée, jamais dupliquée, pour qu'une re-navigation laisse exactement un exemplaire de chacune.
BlogPosting (headline, dates, auteur, éditeur, image) doublé d'un BreadcrumbList . Le composant de détail le pilote dans un effect qui réagit à l'article courant et à la langue : à chaque changement, seo.update réécrit les balises et seo.setArticleJsonLd remplace le graphe, toujours en poser-ou-remplacer.
gen:seo produit les artefacts de site après le build. Le sitemap.xml émet chaque concept (page, article, série) une fois par langue, chaque <url> portant sa grappe complète de hreflang et un lastmod réel : la date de l'article sur sa propre page, la plus récente sur les pages qui évoluent, aucune date inventée sur les pages statiques (un lastmod faux vaut moins que pas de lastmod ). Le robots.txt autorise explicitement les crawlers d'IA (GPTBot, ClaudeBot, PerplexityBot…) en plus des moteurs classiques, et un llms.txt liste les articles et décrit l'auteur comme entité.
La configuration d'edge d'Azure SWA
staticwebapp.config.json à la racine du déploiement. Ici il tient en dix-sept lignes.
/ redirige vers /fr en 301 : la home canonique est localisée. Les deux en-têtes globaux Cross-Origin-Opener-Policy et Cross-Origin-Embedder-Policy isolent le document (cross-origin isolation), condition pour que le SharedArrayBuffer du moteur de jeu embarqué fonctionne dans son worker. Ils doivent rester globaux, pas limités à une route, sinon le worker perd l'accès au buffer partagé. Les bundles hashés ( *.css , *.js , *.mjs ) partent avec un cache immuable d'un an, sûr parce qu' outputHashing: all change le nom du fichier dès que son contenu bouge.
navigationFallback . La bascule SPA classique (route inconnue réécrite vers index.html ) n'a pas lieu d'être, puisque chaque route est déjà prérendue dans son propre index.html . SWA sert le fichier directement, et une URL réellement inconnue tombe sur le 404.html via responseOverrides . Le détail des clés est dans le guide de configuration Static Web Apps .
Le déploiement, sans secret stocké
deploy-client.yml se déclenche quand client/** change sur main . Il ne stocke aucun secret Azure : la connexion passe par OIDC ( azure/login avec id-token: write ), et même le jeton de déploiement SWA est lu à l'exécution, via az staticwebapp secrets list … --query properties.apiKey , puis injecté dans l'environnement du job. Aucune clé n'est figée dans les secrets du dépôt.
npm ci , npm run build:ssg (la chaîne complète, garde comprise), puis swa deploy dist/super-dev-portfolio/browser avec le jeton fraîchement lu. Le dossier browser/ est l'intégralité du site : il n'y a pas d'artefact serveur à côté.
Le SSG dépasse la seule question du référencement. Chaque page existe en HTML avant tout JavaScript, sa langue et ses données structurées figées au build, et un script casse la livraison si l'une d'elles part vide. Le time-to-content ne dépend plus de la connexion du visiteur ni de l'exécution d'un bundle.