Créer un site web statique avec Angular

Comment allier la flexibilité et la puissance d'Angular avec le SEO, le tout sans faire appel à du SSR (Server Side Rendering) dynamique. Nous allons voir ensemble dans cet article comment générer un site statique compréhensible par les moteurs de recherche au moment de la compilation.

Créer un site web statique avec Angular
English version available here : Create a static website with Angular

Le problème que nous essayons de résoudre

Il est très probable que si vous êtes en train de lire ces lignes, vous êtes convaincu par Angular et que vous vous posez des questions concernant le SEO (référencement) de votre site / application ou que vous voulez booster son load time.

Alors qu'est-ce que l'on va essayer de résoudre ici ?

Partons d'une application Angular très simple que vous pourrez retrouver sur mon Github à l'adresse suivante : https://github.com/Chinouchi/angular-to-static. La branche master contient le site sans génération statique; la branche feature/static-website contient le site final, avec la génération statique que vous obtiendrez en suivant cet article.

Il s'agit d'un site assez classique contenant 3 pages :

  • Une landing page : présentation de notre produit / service, probablement LA page la plus importante que les moteurs de recherche doivent voir.
  • Une page "My todo" : mini-application de type todo list, qui apporte du dynamisme à notre site (la donnée est stockée en mémoire pour cet exemple)
  • Une page "About" : Mini page statique complémentaire qui aura également besoin d'être référencée.

Voici ce qui est retourné par le serveur web lorsque l'on consulte la page le site à la racine ( / ) qui correspond à la landing page.

<!doctype html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <title>My static Angular web site</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <!-- Some links I hided because irrelevant here -->
</head>

<body>
  <app-root></app-root>

  <!-- Some scripts I hided because irrelevant here -->
  <script src="runtime.js" type="module"></script><script src="polyfills.js" type="module"></script><script src="styles.js" type="module"></script><script src="vendor.js" type="module"></script><script src="main.js" type="module"></script>
  </body>

</html>

Autant dire...pas grand chose, il s'agit en fait réellement de la page index.html que l'on trouve dans notre projet Angular dans laquelle sont injectés les différents scripts JS qui composent notre application Angular. Et si l'on essaie de charger la page /about, on obtient exactement la même chose.

Et c'est tout à fait normal car c'est Angular, lors de son chargement, qui met tout en place et injecte les components et routes en fonction de ce qui est demandé dynamiquement. Sauf que les moteurs de recherche parsent et analysent les fichiers HTML retournés par les sites pour faire le référencement, autrement dit dans notre cas, c'est la cata, il n'y a aucune information à référencer !

Avec l'émergence des SPA (Single Page Application), les moteurs de recherchent tendent à exécuter le javascript d'initialisation afin d'éviter justement de ne pas voir le vrai contenu, mais plus vous facilitez la vie des moteurs de recherche, plus ils vous aimeront ;)

Un autre point important à noter ici, l'utilisateur de votre site ne pourra voir et interagir qu'à partir du moment où le javascript aura été téléchargé et l'initialisation exécutée.

Vous pouvez bien sur limiter la taille du bundle initial d'Angular avec les modules et le lazy loading, mais vous n'obtiendrez dans tous les cas jamais la performance d'un site statique.
Pour obtenir plus d'information sur les métriques qui favorisent votre référencement, je vous invite à consulter https://developers.google.com/speed/docs/insights/v5/about et d'utiliser Page Speed Insights si vous souhaitez obtenir un rapport détaillé sur la vitesse de chargement de votre site.

Présentation du SSR (Server Side Rendering)

Vous en avez peut-être déjà entendu parler, la solution pour les SPA pour contourner les problèmes énoncés plus haut tourne autour du SSR. Angular propose Angular Universal comme solution de SSR. Le concept est en fait très simple, lorsqu'un utilisateur demande l'adresse /about par exemple, le serveur s'occupe de charge le contexte Angular, initialiser les components (l'appel à des API extérieures est fait à ce moment là) et créer le HTML final qui représente l'état de l'application après chargement. Généralement cela permet au client d'obtenir le visuel de la page plus rapidement que ce que pourrait faire son navigateur en évitant les nombreux aller / retour client <-> serveur.

Une fois que le client (le navigateur) reçoit la page HTML déjà construite il l'affiche de suite puis charge le reste du contexte Angular en background. L'utilisateur peut donc commencer à visualiser le contenu utile avant même que le navigateur ait terminé de charger le javascript d'Angular. Intervient ensuite le processus de "Rehydration" qui permet de charger le javascript sans que le travail d'initialisation déjà fait par le serveur soit réexécuté.

Vous l'aurez compris, nos deux "pain points" précédents sont résolus : un chargement initial plus rapide car c'est le serveur qui créé la page HTML initial avec ses ressources locales (et éventuellement des API externes) et un fichier HTML retourné qui n'est plus "vide" : parfait pour les moteurs de recherche un peu feignants.

C'est donc effectivement une excellente solution mais qui présente quelques désavantages :

  • Le serveur a plus de boulot, il gérera donc moins de requêtes à la seconde.
  • Il y a besoin d'un serveur pour distribuer votre application de type SPA ! Et c'est plus particulièrement ce point là que je souhaite éviter dans pas mal de cas sur mes applications. J'aime bien personnellement stocker mes app Angular dans un Blob Storage Azure pour sa simplicité, sa scalabilité infinie et surtout son coût quasi nul.

Mise en place du pre-rendering

Alors, comment aller plus loin ? Eh bien tout simplement en faisant le boulot du SSR non pas à chaque requête mais au moment du "build" de votre application.

Le SSR dans Angular avec Angular Universal existe depuis un bout de temps. Le pre-rendering fait par la core team et aussi simple que ce que nous allons voir existe depuis Angular 9. Pour les versions précédentes, il existe des astuces mais il vous faudra ruser pour arriver à vos fins et ce l'on va voir maintenant ne fonctionnera pas out of the box

La première étape va être d'installer le SSR dans notre application Angular. C'est très simple grâce aux schematics et au ng add.

ng add @nguniversal/express-engine

Nous allons détailler un peu ce qu'il s'est passé avec cette simple commande pour ne pas rester dans l'ignorance et comprendre quelle sorte de magie a opérée :)

Un nouveau démarrage de notre application Angular

L'ajout d'Angular Universal a créé plusieurs fichiers liés au démarrage et au chargement de notre application dans un contexte serveur.

  • Un fichier tsconfig.server.json : dérivé du fichier tsconfig.app.json, permet de configurer spécifiquement la transpilation Typescript -> javascript pour le contexte de l'exécution serveur.
  • Un fichier server.ts : contient une application node.js utilisant la librairie express pour servir de serveur web lors d'une requête SSR.
  • Un fichier main.server.ts : remplace le démarrage classique d'Angular qui est prévu pour s'exécuter dans un navigateur.
  • Un fichier app.server.module.ts : nouveau module utilisé lors du démarrage de l'application coté serveur, qui importe le module ServerModule en supplément de l'AppModule "classique".
Fichiers ajoutés par Angular Universal
Liste des fichiers après l'ajout d'Angular Universal

D'autres fichiers ont été altérés par la commande ng add :

  • angular.json : Rajout d'une configuration "server", d'une "server-ssr" et enfin une dernière "prerender" (tiens tiens... celle-ci pourra nous intéresser particulièrement !)
  • app.module.ts : Modification de l'import BrowserModule.withServerTransition({ appId: 'serverApp' }) qui permet de configurer le processus de "Rehydration"
  • app-routing.module.ts : Changement sur l'import du RouterModule necessaire au bon fonctionnement du SSR
  • package.json : outre les librairies ajoutées, des "scripts" ont été mis en place permettant de piloter les différentes actions liées au SSR.
"scripts": {
	...
  "dev:ssr": "ng run angular-to-static:serve-ssr",
  "serve:ssr": "node dist/angular-to-static/server/main.js",
  "build:ssr": "ng build --prod && ng run angular-to-static:server:production",
  "prerender": "ng run angular-to-static:prerender"
}
Scripts npm ajoutés

Test du SSR mis en place

Au lieu d'utiliser le bon vieux ng serve dont vous avez l'habitude, vous pouvez utiliser npm run dev:ssr pour exécuter votre application avec le SSR activé.

Et désormais, voici ce qui est retourné directement lors d'un appel serveur.

<!DOCTYPE html><html lang="en">
<head>
  <meta charset="utf-8">
  <title>My static Angular web site</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <!-- Links hidden because irrelevant here -->
 </head>

<body>
  <app-root _nghost-sc50="" ng-version="10.0.5"><div _ngcontent-sc50="" class="d-flex flex-column root"><header _ngcontent-sc50="" class="bg-dark"><app-header _ngcontent-sc50="" _nghost-sc48=""><div _ngcontent-sc48="" class="container"><nav _ngcontent-sc48="" class="navbar navbar-expand-md no-gutters"><div _ngcontent-sc48="" class="col-2 text-left"><p _ngcontent-sc48=""><a _ngcontent-sc48="" href="https://www.antoinebernard.com"><img _ngcontent-sc48="" src="https://www.antoinebernard.com/content/images/size/w1000/2020/07/Logo-blog-2.png" height="30" alt="image"></a></p></div><button _ngcontent-sc48="" type="button" data-toggle="collapse" data-target="#navbarNav4" aria-controls="navbarNav4" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"><span _ngcontent-sc48="" class="navbar-toggler-icon"></span></button><div _ngcontent-sc48="" id="navbarNav4" class="collapse navbar-collapse justify-content-center col-md-8"><ul _ngcontent-sc48="" class="navbar-nav justify-content-center"><li _ngcontent-sc48="" class="nav-item"><a _ngcontent-sc48="" contenteditable="false" routerlink="/" routerlinkactive="active" class="nav-link active" ng-reflect-router-link="/" ng-reflect-router-link-active="active" ng-reflect-router-link-active-options="[object Object]" href="/">Home </a></li><li _ngcontent-sc48="" class="nav-item"><a _ngcontent-sc48="" contenteditable="false" routerlink="/todo" routerlinkactive="active" class="nav-link" ng-reflect-router-link="/todo" ng-reflect-router-link-active="active" href="/todo">My todo</a></li><li _ngcontent-sc48="" class="nav-item"><a _ngcontent-sc48="" contenteditable="false" routerlink="/about" routerlinkactive="active" class="nav-link" ng-reflect-router-link="/about" ng-reflect-router-link-active="active" href="/about">About</a></li></ul></div><ul _ngcontent-sc48="" class="navbar-nav col-2 justify-content-end d-none d-md-flex"><li _ngcontent-sc48="" class="nav-item"><a _ngcontent-sc48="" contenteditable="false" href="https://www.twitter.com/chinouchi" class="nav-link"><em _ngcontent-sc48="" class="fab fa-twitter"></em></a></li><li _ngcontent-sc48="" class="nav-item"><a _ngcontent-sc48="" contenteditable="false" href="https://www.github.com/chinouchi" class="nav-link"><em _ngcontent-sc48="" class="fab fa-github"></em></a></li></ul></nav></div></app-header></header><div _ngcontent-sc50=""><router-outlet _ngcontent-sc50=""></router-outlet><app-home-page _nghost-sc45=""><section _ngcontent-sc45="" class="fdb-block py-0"><div _ngcontent-sc45="" class="container py-5 my-5" style="background-image: url(https://cdn.jsdelivr.net/gh/froala/design-blocks@master/dist/imgs//shapes/2.svg);"><div _ngcontent-sc45="" class="row justify-content-center py-5"><div _ngcontent-sc45="" class="col-12 col-md-10 col-lg-8 text-center"><div _ngcontent-sc45="" class="fdb-box"><h1 _ngcontent-sc45="">Angular static website</h1><p _ngcontent-sc45="" class="lead">Enable lighting fast load time for your angular application and enable SEO discoverability without the need for an SSR server.</p><p _ngcontent-sc45="" class="mt-4"><a _ngcontent-sc45="" href="#" class="btn btn-primary">Looks great !</a></p></div></div></div></div></section><section _ngcontent-sc45="" class="fdb-block"><div _ngcontent-sc45="" class="container"><div _ngcontent-sc45="" class="row text-center"><div _ngcontent-sc45="" class="col-12"><h1 _ngcontent-sc45="">Features</h1><p _ngcontent-sc45=""><img _ngcontent-sc45="" alt="image" src="https://cdn.jsdelivr.net/gh/froala/design-blocks@master/dist/imgs//draws/email.svg" class="img-fluid mt-5"></p></div></div><div _ngcontent-sc45="" class="row text-center justify-content-center mt-5 pt-5"><div _ngcontent-sc45="" class="col-12 col-sm-4 col-lg-3 m-md-auto"><p _ngcontent-sc45=""><em _ngcontent-sc45="" class="fa fa-bolt fa-3x" style="color: #ffe714;"></em></p><h3 _ngcontent-sc45=""><strong _ngcontent-sc45="">Lighting fast load time</strong></h3></div><div _ngcontent-sc45="" class="col-12 col-sm-4 col-lg-3 m-auto pt-4 pt-sm-0"><p _ngcontent-sc45=""><img _ngcontent-sc45="" alt="image" src="https://angular.io/assets/images/logos/angular/angular.svg" class="fdb-icon"></p><h3 _ngcontent-sc45=""><strong _ngcontent-sc45="">Full Angular enabled</strong></h3></div><div _ngcontent-sc45="" class="col-12 col-sm-4 col-lg-3 m-auto pt-4 pt-sm-0"><p _ngcontent-sc45=""><em _ngcontent-sc45="" class="fa fa-eye fa-3x"></em></p><h3 _ngcontent-sc45=""><strong _ngcontent-sc45="">Ready for SEO</strong></h3></div></div></div></section><section _ngcontent-sc45="" class="fdb-block bg-dark"><div _ngcontent-sc45="" class="container"><div _ngcontent-sc45="" class="row text-center"><div _ngcontent-sc45="" class="col-12"><h1 _ngcontent-sc45="">Made with <em _ngcontent-sc45="" class="fas fa-heart text-danger"></em> with <a _ngcontent-sc45="" href="https://froala.com">Froala</a></h1></div></div></div></section><section _ngcontent-sc45="" class="fdb-block pt-0"><div _ngcontent-sc45="" class="container-fluid p-0 pb-md-5"><iframe _ngcontent-sc45="" src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2848.8444388087937!2d26.101253041406952!3d44.43635311654287!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x40b1ff4770adb5b7%3A0x58147f39579fe6fa!2zR3J1cHVsIFN0YXR1YXIgIkPEg3J1yJthIEN1IFBhaWHIm2Ui!5e0!3m2!1sen!2sro!4v1507381157656" width="100%" height="300" frameborder="0" allowfullscreen="" class="map" style="border: 0;"></iframe></div><div _ngcontent-sc45="" class="container"><div _ngcontent-sc45="" class="row mt-5"><div _ngcontent-sc45="" class="col-12 col-md-6 col-lg-5"><h2 _ngcontent-sc45="">Contact Us</h2><p _ngcontent-sc45="" class="lead">Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. Separated they live in Bookmarksgrove.</p><p _ngcontent-sc45="" class="lead">It is a paradisematic country, in which roasted parts of sentences fly into your mouth. </p></div><div _ngcontent-sc45="" class="col-12 col-md-6 ml-auto pt-5 pt-md-0"><form _ngcontent-sc45="" novalidate="" class="ng-untouched ng-pristine ng-valid"><div _ngcontent-sc45="" class="row"><div _ngcontent-sc45="" class="col"><input _ngcontent-sc45="" type="text" placeholder="First name" class="form-control"></div><div _ngcontent-sc45="" class="col"><input _ngcontent-sc45="" type="text" placeholder="Last name" class="form-control"></div></div><div _ngcontent-sc45="" class="row mt-4"><div _ngcontent-sc45="" class="col"><input _ngcontent-sc45="" type="email" placeholder="Enter email" class="form-control"></div></div><div _ngcontent-sc45="" class="row mt-4"><div _ngcontent-sc45="" class="col"><input _ngcontent-sc45="" type="email" placeholder="Subject" class="form-control"></div></div><div _ngcontent-sc45="" class="row mt-4"><div _ngcontent-sc45="" class="col"><textarea _ngcontent-sc45="" name="message" rows="3" placeholder="How can we help?" class="form-control"></textarea></div></div><div _ngcontent-sc45="" class="row mt-4"><div _ngcontent-sc45="" class="col"><button _ngcontent-sc45="" type="submit" class="btn btn-primary">Submit</button></div></div></form></div></div></div></section></app-home-page><!--container--></div><footer _ngcontent-sc50="" class="fdb-block footer-small fp-active bg-dark mt-auto"><app-footer _ngcontent-sc50="" _nghost-sc49=""><div _ngcontent-sc49="" class="container"><div _ngcontent-sc49="" class="row align-items-center text-center"><div _ngcontent-sc49="" class="col-12 col-lg-4 text-lg-left"><p _ngcontent-sc49="">© 2020 Antoine BERNARD</p></div><div _ngcontent-sc49="" class="col-12 col-lg-4 mt-4 mt-lg-0"><p _ngcontent-sc49=""><img _ngcontent-sc49="" alt="image" src="https://www.antoinebernard.com/content/images/size/w1000/2020/07/Logo-blog-2.png" height="40"></p></div><div _ngcontent-sc49="" class="col-12 col-lg-4 text-lg-right mt-4 mt-lg-0"><ul _ngcontent-sc49="" class="nav justify-content-lg-end justify-content-center"><li _ngcontent-sc49="" class="nav-item"><a _ngcontent-sc49="" href="https://www.twitter.com/chinouchi" class="nav-link">Follow me on twitter</a></li><li _ngcontent-sc49="" class="nav-item"><a _ngcontent-sc49="" href="https://www.github.com/chinouchi" class="nav-link">My github</a></li><li _ngcontent-sc49="" class="nav-item"><a _ngcontent-sc49="" routerlink="/about" class="nav-link" ng-reflect-router-link="/about" href="/about">About</a></li></ul></div></div></div></app-footer></footer></div></app-root>

  <!-- Some scripts irrelevant here -->
<script src="runtime.js" type="module"></script><script src="polyfills.js" type="module"></script><script src="vendor.js" type="module"></script><script src="main.js" type="module"></script>

</body></html>

La ligne 12 est très longue mais contient l'intégralité de notre page d'accueil qui est directement présentée au navigateur ! Les liens ont aussi été transformés en href pour que les moteurs de recherche puissent facilement les suivre.

Pour autant, si vous changez de page dans l'application, vous remarquerez que vous conserver tout la fluidité d'une application SPA, sans faire un autre appel serveur car tout le contexte Angular est venu s'attacher au fichier HTML déjà existant. L'application Todo fonctionne comme auparavant, l'utilisateur n'y voit que du feu.

npm run prerender

Enfin nous arrivons à notre dernière étape qui consiste à générer d'avance toutes les pages de notre application au format HTML. La encore l'équipe Angular a prévu le coup et nous a préparé un script npm qu'il suffit de lancer avec la commande npm run prerender.

Cette commande va builder en mode production votre application, trouver et explorer votre routing, lancer le serveur s'occupant du SSR et appeler toutes les routes les unes après les autres avant de finalement sauvegarder la sortie HTML dans un fichier index.html dans un dossier au nom de la route.

Le dossier dist généré par le pre-render
Le dossier dist généré par la tache de pre-render

Dans le dossier dist, vous obtenez un répertoire par route contenant son fichier HTML. Vous pouvez déployer ce dossier sur un serveur HTTP très basique sans Node, ni PHP ni ASP.NET, tant qu'il sait retourner des fichiers statiques, vous êtes bon !
Comme dit précédemment, je suis friand de déployer ceci dans un Blob Storage Azure, mais j'y reviendrai dans un prochain article.

Les services Meta et Title d'Angular pour mieux personnaliser le référencement

Avant de clôturer cet article, je tiens à vous partager une dernière information. Si votre objectif derrière le SSR est avant tout le référencement, vous ne pouvez pas passer à coter des services Title et Meta fournis par Angular.

Comme leur nom l'indique ils permettent respectivement de changer le Title et les Meta de vos pages, et il est indispensable d'en prendre soin si vous comptez vous faire référencer efficacement par les moteurs de recherche.
En plus ils sont très simple à utiliser.

Modifier le titre et les metas de votre page dans le ngOnInit() afin que le SSR le prenne en compte et retourne le HTML avec cette information :

constructor(private title: Title, private meta: Meta) { }

ngOnInit(): void {
  // Modification of the title
  this.title.setTitle('Angular Static Web App - The easiest way to get a static website');

  // Modification of the metas
  this.meta.addTag({
      name: 'description',
      content: 'The best way to get a static web site easily discoverable by search engine is to use the pre-render Angular application using built-in SSR at build time'
  })
}
Modification du title et des metas page par page

Comme on pouvait s'y attendre le pre-render de cette page donne le résultat suivant :

<title>Angular Static Web App - The easiest way to get a static website</title>
<meta name="description"
    content="The best way to get a static web site easily discoverable by search engine is to use the pre-render Angular application using built-in SSR at build time">

Conclusion

Le pre-render fourni avec Angular Universal vous permet rapidement et simplement de transformer une application Angular en site statique. Les deux principaux bénéfices sont : indexation facilitée pour les moteurs de recherche et rapidité de votre site (qui est aussi un facteur pour les moteurs de recherche).

Il vous permet de vous passer d'un serveur d'exécution et sera parfaitement adapté pour des sites dont le contenu peut être rafraichi uniquement à chaque déploiement. Dans le cas contraire, vous connaissez désormais la base du SSR qui vous permettra d'obtenir des versions statiques à jour de vos pages.

Dans un prochain article, je reprendrai ce projet pour faire le pre-render et le déploiement automatisé dans un Azure Blob Storage à chaque push / PR sur la branche master.

Si mes articles vous plaisent, pensez à me suivre sur Twitter @Chinouchi pour être au courant de la sortie des prochains :)

Annexes