Create a static website with Angular

How to combine the flexibility and power of Angular with SEO friendly site. We will see together in this article how to generate a static site at build time.

Create a static website with Angular
Version française disponible ici : Créer un site statique avec Angular

The problem we're trying to solve

It is very likely that if you are reading these lines, you are convinced by Angular and you have questions about the SEO (Search Engine Optimization) of your site / application or that you want to boost its load time.

So what are we going to solve here?

Let's start with a very simple Angular application that you can find on my Github at the following address: https://github.com/Chinouchi/angular-to-static. The master branch contains the site without static generation; the feature/static-website branch contains the final site, with the static generation that you will get by following this post.

This is a fairly classic site containing 3 pages:

  • A landing page: presentation of our product / service, probably THE most important page search engines must see and index properly.
  • A "My todo" page: mini-application of "todo list" type, which brings dynamism to our site (the data is stored in memory for this example)
  • An "About" page: Additional static mini page which will also need to be SEO enabled.

Here is what is returned by the web server when we consult the page the site at the root (/) which corresponds to the 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>

Not much... right ? It is actually the plain index.html page that we find in our Angular project in which are injected the different JS scripts that make up our Angular application. And if we try to load the /about page, we get exactly the same thing.

And this is absolutely normal because it is Angular which sets everything up and injects the components and routes according to what is dynamically requested. Except that the search engines parse and analyze the HTML files returned by the sites to do the indexing, in other words in our case, it is the disaster, there is no information to index!

With the emergence of SPAs (Single Page Application), search engines tend to execute initialization javascript in order to avoid not seeing the real content, but the more you make life easier for them, the more they might like you;)

Another important point to note here, the user of your site will not be able to see and interact until the javascript has been downloaded and the initialization executed.

You can of course limit the size of Angular's initial bundle with modules and lazy loading, but you will never get the performance of a static site.
For more information on the metrics that promote your SEO, I invite you to visit https://developers.google.com/speed/docs/insights/v5/about and use Page Speed Insights if you want to get a detailed report on the loading speed of your site.

Introduction to SSR (Server Side Rendering)

You may have heard of it before, the solution for SPAs to get around the SEO issues is SSR. Angular has "Angular Universal" as a SSR solution. The concept is actually very simple, when a user asks for the /about address for example, the server takes care of loading the Angular context, initializing the components (the call to external APIs is made at this time) and create the final HTML which represents the state of the application after loading. Generally this allows the client to obtain the visual of the page faster than what his browser could do by avoiding the numerous client <-> server round trips.

Once the client (the browser) receives the HTML page already built, it displays it immediately then loads the rest of the Angular context in the background. The user can therefore start viewing useful content even before the browser has finished loading Angular's javascript. Then comes the "Rehydration" process which allows the javascript to be loaded without the initialization work already done by the server being rerun.

Now, our two previous pain points are resolved: a faster initial load because the server creates the initial HTML page with its local resources (and possibly external APIs) and a returned HTML file which no longer "empty": perfect for lazy search engines.

It is as you can see an excellent solution but it also has some disadvantages:

  • The server has more work to do, so it will handle fewer requests per second.
  • You need a server to distribute your SPA-type application! And this is the major point I want to avoid most of the times. I personally like to store my Angular apps in Azure Blob Storage for its simplicity, its infinite scalability and especially its almost zero cost.

Implementation of pre-rendering

So how can we go further? Well, it is quite simply by doing the job of SSR not at each request but at the time of the build of your application.

SSR in Angular with Angular Universal has been around for a while. The pre-rendering done by the core team, as easy to use as we are going to see, exists since Angular 9. For the previous versions, there are tips and trick to do it but you will have to get your hands dirty to achieve your ends and what we are going to see now will not work out of the box

The first step will be to install the SSR in our Angular application. It's very simple thanks to the schematics and the ng add.

ng add @nguniversal/express-engine

We are going to detail a bit what happened with this simple command so we understand what kind of magic happened :)

A new bootstrap of our Angular application

Adding Angular Universal created several files related to starting and loading our application in a server context.

  • A tsconfig.server.json file: derived from the tsconfig.app.json file, allows you to specifically configure Typescript -> javascript transpilation for the server execution context.
  • A server.ts file: contains a node.js application using the express library for "web server" service during an SSR request
  • A main.server.ts file: replaces the classic Angular startup which is only intended to run in a browser.
  • An app.server.module.ts file: new module used when starting the application on the server side, which imports the ServerModule module in addition to the "classic" AppModule.
Files added by Angular Universal
List of files after adding Angular Universal

Other files have been edited by the ng add command:

  • angular.json: Addition of a "server" configuration, of a "server-ssr" and finally a "prerender"  one (hmmm... this one looks very promising!)
  • app.module.ts: Modification of the BrowserModule.withServerTransition import ({appId: 'serverApp'}) which allows to configure the "Rehydration" process
  • app-routing.module.ts: Change on the import of the RouterModule necessary for the proper functioning of the SSR
  • package.json: in addition to the added libraries, "scripts" have been put in place to control the various commands related to 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"
}
npm scripts added

Test of the newly configured SSR

Instead of using the good old ng serve you are used to, you can use npm run dev: ssr to run your application with SSR enabled.

And now, here is what is returned directly during a server call.

<!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"><i _ngcontent-sc48="" class="fab fa-twitter"></i></a></li><li _ngcontent-sc48="" class="nav-item"><a _ngcontent-sc48="" contenteditable="false" href="https://www.github.com/chinouchi" class="nav-link"><i _ngcontent-sc48="" class="fab fa-github"></i></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=""><i _ngcontent-sc45="" class="fa fa-bolt fa-3x" style="color: #ffe714;"></i></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=""><i _ngcontent-sc45="" class="fa fa-eye fa-3x"></i></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 <i _ngcontent-sc45="" class="fas fa-heart text-danger"></i> 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>

Line 12 is very long but contains our entire home page which is presented directly to the browser! The links have also been turned into href so that search engines can easily follow them.

However, if you change the page in the application, you will notice that you keep all the benefits of a SPA application, without making another server call because all the Angular context is attached to the already existing HTML file. The Todo application works as before, the SSR work is completely transparent for the user.

npm run prerender

We now come to our last step which consists of generating in advance all the pages of our application in HTML format. Here again, the Angular team has planned and prepared an npm script that we just have to run with the npm run prerender command.

This command will build your application in production mode, find and explore your routing, launch the server taking care of the SSR and call all the routes one after the other before finally saving the HTML output in index.html files put in a folder with the name of the route.

The dist folder generated by the pre-render task
The dist folder generated by the pre-render task

In the dist folder, you get a directory per route containing its HTML file. You can deploy this folder to a very basic HTTP server without Node, PHP or ASP.NET, as long as it knows how to return static files you're good to go!
As said before, I personally like to deploy this to Azure Blob Storage. More about this in a future post !

Angular's Meta and Title services to better personalize your SEO

Before closing this article, I want to share with you one last piece of information. If your goal behind SSR is SEO first and foremost, you have to know about Title and Meta services provided by Angular.

As their name suggests, they allow you to respectively change the Title and Meta of your pages, and it is essential to cherish them if you plan to be well positioned in search engines.
They are also very easy to use.

Modify the title and the metas of your page in the ngOnInit() so that the SSR takes it into account and returns the HTML with this 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 of the title and metas page by page

Conclusion

The pre-render feature provided with Angular Universal allows you to quickly and easily transform an Angular application into a static website. The two main benefits are: better indexing for search engines and speed of your site (which is also a factor for search engines).

It avoids the need of an execution server and will be perfectly suited for sites whose content can be refreshed only with each deployment. Otherwise, you now know the basis of SSR which will allow you to obtain updated static versions of your pages on each request.

In a future article, I will take up this project to do the pre-render and automated deployment in Azure Blob Storage at each push / PR on the master branch.

If you like my articles, please follow me on Twitter @Chinouchi to be informed of the release of the next ones :)

Appendices