SEO-Friendly Angular SPA: Universal Server-Side Rendering Tutorial

In a rush? Skip to technical tutorial or live demo.

Google's JavaScript crawling & rendering is still a somewhat obscure issue.

Contradictory statements and experiments are found all over the web.

So what does this mean?

As a developer, you NEED to optimize sites built with popular JS frameworks for SEO.

With that in mind, here's the third part of our ongoing JavaScript Frameworks SEO issues series:

  1. Building a prerendered Vue.js app

  2. Applying React SEO with Next.js.

  3. Here, I'll be tackling Angular SEO.

More precisely, we'll be crafting a server-rendered Angular e-commerce SPA using Universal.

Here are the steps we'll use to achieve this:

  1. Setting up an Angular project.

  2. Creating Angular components.

  3. Enabling e-commerce functionality on our SPA.

  4. Using Universal to make our Angular app SEO-friendly

This should be fun!

Why bother with Angular SEO? Isn't Angular Google-backed?

Angular is an open-source JS framework developed by Google engineers in 2010.

It's great to complete your headless stack or any JAMstack for that matter. But it still shares common SEO issues known to JavaScript frameworks.

JS single-page apps add content to pages dynamically with JavaScript. This isn't optimal for SEO: crawlers most likely won't run the JS code, thus not encountering the actual content of the page.

As of 2018, word is that Google can crawl and render JavaScript-filled pages, thus reading them like modern browsers do. Although quotes from the giant itself make me not so optimistic:

"Times have changed. Today, as long as you’re not blocking Googlebot from crawling your JavaScript or CSS files, we are generally able to render and understand your web pages like modern browsers."

"Sometimes things don’t go perfectly during rendering, which may negatively impact search results for your site."

"Sometimes the JavaScript may be too complex or arcane for us to execute, in which case we can’t render the page fully and accurately."

While Google struggles to render your JS, it STILL beats all the other search or social crawlers (Facebook, Twitter, LinkedIn). Optimizing the rendering of your app will thus benefit your activities on these channels too!

That's way too much unindexed JavaScript content to leave on the table.

How to handle Angular SEO issues

For your Angular website to find its way on top of search engine results, you'll need to put in some work.

Fetch as Google

If you already have an Angular public app, head to your Google Search Console and run Fetch as Google on the pages that need indexing. It'll tell you what Googlebots can or cannot access.

It'll give you an idea of which areas need some SEO work.

The ways to do it don't really differ from what we've already seen with Vue or React, but the tools do.

Prerendering

This one is quite simple. JavaScript is rendered in a browser; static HTML is saved and returned to the crawlers. This solution is great for simple apps that don't rely on any server. It's easier to setup than server-side rendering, with great SEO results nonetheless.

For Angular prerendering, I suggest looking at Prerender.io, or the new kid on the block, Scully.

Server-side rendering

That's what I'll do here.

I'll make use of SSR using Angular Universal.

To put it simply, this will run Angular on the backend so that when a request is made, the content will be rendered in the DOM for the user.

Although this method adds more stress on the server, it can improve performance on slower devices/connections as the first page will load faster than if the client had to render the page itself.

We've explored these two methods in this Vue.js video tutorial. The content also applies for Angular!

Does using these techniques mean you'll suddenly appear on top of the SERP? Well, maybe. Most likely though? Nope. There are lots of other SEO considerations: great mobile UX, HTTPS connexion, sitemap, great content, backlinks, etc.

Technical tutorial: Angular SEO-friendly SPA example with Universal

Pre-requisites

  • Basic understanding of single-page applications (SPA)

  • Basic knowledge of Typescript [optional]

  • A Snipcart account (forever free in Test mode)

Setting up the development environment

Install the Angular CLI globally using the following command:

npm install -g @angular/cli

I'm using sass in my project. If you choose to do so and don't have it installed already, well:

npm install -g sass

1. Setting up the project structure using Angular CLI

Create your project using Angular CLI.

ng new my-app --style=scss --routing

Noticed how I added the style and routing argument to the command?

This way, routing and scss will be inserted directly into your project, which is much easier than trying to add it later.

Once this is done, go to the project directory and serve your project.

cd my-app
ng serve

The project should now be visible locally at: http://localhost:4200/

2. Creating the first Angular component

Like many modern frontend libraries or framework, Angular uses a component system.

In Angular, each component except the main app one is a directory located in src/app/ containing three files: a TypeScript file, a styling file, and an HTML file.

Since this demo is a simple e-commerce store, I'll use two components. The products component will contain a list, and a link to each product page. And the product component will display all the product detail information.

Use Angular CLI's built-in command to generate new components:

ng generate component products
ng generate component product

2.1 Mocking a list of products

Before editing the components, you'll need to create a data structure for products.

Generate a product.ts file directly in the src/app/ folder and give it all the properties you want.

export class Product{
    id: string;
    name: string;
    price: number;
    weight: number;
    description: string;
}

Also create mocked-product.ts in the same location. This file will import the Product class and export a Product array.

This way, you'll be able to import the list of products into any component.

import { Product } from './product';

export const PRODUCTS: Product[] = [
    {
        id: "ac-1",
        name: "Central Air Conditioner",
        price: 5000.00,
        weight: 900000,
        description: "Keep the whole office cool with this Central Air Conditioner."
    },
    {
        id: "ac-2",
        name: "Window Air Conditioner",
        price: 300.00,
        weight: 175000,
        description: "Perfect to keep a room or small apartment cool."
    },
    {
        id: "ac-3",
        name: "A fan",
        price: 10.00,
        weight: 2000,
        description: "An inexpensive, but effective way to stop your coworkers from complaining about the heat."
    },
] 

3. Listing the app's products

Okay, product successfully mocked! Now let's list our products on the home page. To do so, open the products.component.ts and add:

import { Component, OnInit } from '@angular/core';
import { PRODUCTS } from '../mocked-products';

@Component({
  selector: 'app-products',
  templateUrl: './products.component.html',
  styleUrls: ['./products.component.scss']
})

export class ProductsComponent implements OnInit {
  products = PRODUCTS;

  constructor() { }

  ngOnInit() { }
}

As you can see, each Angular components imports the Component class from the Angular core library. @Component({}) is a decorator function that marks a class as an Angular component and provides most of its metadata.

The selector key pair value is the XML tag that you can include in templates to render that component.

Do this now by removing everything generated in the template of the main app (app.component.html) and add in the appropriate tag:

<app-products></app-products>

Once it's done, if you visit the website you should be greeted with:

Now, let's modify the products.component.html file to list products using Angular's repeater directive *ngFor and the handlebars ({{ }}) to bind data from your class to the template.

<h2>Products</h2>
<ul *ngFor="let product of products">
  <li>{{product.name}}</li>
</ul>

4. Adding routing to the Angular app

Let's turn this store into a single-page app using Angular's built-in routing.

Since you've added the --routing argument when creating the project, you can go directly into app-routing.module.ts and refactor it into the following code:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ProductComponent } from './product/product.component';
import { ProductsComponent } from './products/products.component';

const routes: Routes = [
  {path: '', component: ProductsComponent},
  {path: 'product/:id', component: ProductComponent}
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

This module imports products and product components, and builds an array of routes linking each path to a component.

You can see I've added a :id placeholder that will be able to retrieve in the product component to display the right product.

It is also important to initialize the router by adding RouterModule.forRoot(route) in the imports of the module.

Once this is done, you can replace the component tag in the app's template (app.component.html) with the <router-outlet> tag:

<router-outlet></router-outlet>

The router-outlet tag will render a view for the appropriate path specified in the routes array. In this case, the root directory will render a view for the Products component.

You can now add a routerLink='relativePath' in place of any href='path' attribute in <a> tags. For instance, you can update products.component.html file with something like this:

<h2>Products</h2>
<ul *ngFor="let product of products">
  <li>
    <a routerLink="/product/{{product.id}}">{{product.name}}</a>
  </li>
</ul>

This way, each item in our list will send the user to the view with the product component.

5. Creating the product component

Now let's create the product details component. In its TypeScript file, product.component.ts, add the following code:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';

import { PRODUCTS } from '../mocked-products';
import { Product } from '../product';

@Component({
  selector: 'app-product',
  templateUrl: './product.component.html',
  styleUrls: ['./product.component.scss']
})
export class ProductComponent implements OnInit {
  products = PRODUCTS;
  url: String;
  product: Product;

  constructor(private route: ActivatedRoute, private location: Location) {
    const id = this.route.snapshot.paramMap.get('id');
    this.product = this.findProductById(id);
    this.url = `https://snipcart-angular-universal.herokuapp.com/${this.location.path()}`;
  }

  ngOnInit() { }

  findProductById(productId: string): Product {
    return this.products.find(product => product.id === productId);
  }
} 

Above, I've imported the ActivatedRoute and Location from Angular. It will allow you to get the productId from the URL and get the current path directly in the constructor.

We've also imported our mocked products array, retrieved the current product using the ID from the route using find() and prefixed the URL with the server's requests origin.

Now let's update the component's template (product.component.html) to display the necessary information and create a buy button compatible with Snipcart's product definition.

<div class="product">
  <img src="../assets/images/{{product.id}}.svg" alt="{{product.name}}">
  <h2>{{product.name}}</h2>
  <p>{{product.description}}</p>
  <button 
    class="snipcart-add-item"
    [attr.data-item-id]="product.id"
    [attr.data-item-name]="product.name"
    [attr.data-item-url]="url"
    [attr.data-item-price]="product.price"
    [attr.data-item-weight]="product.weight"
    [attr.data-item-description]="product.description">
    Buy ({{product.price}}$)
  </button>
</div>

Notice how I didn't use the curly brackets to bind data in the HTML attributes?

You can only use curly brackets for properties, not attributes. Therefore, you have to use Angular's attribute binding syntax as demonstrated in the code above.

6. Integrating shopping cart functionalities

Now let's integrate Snipcart by adding the required scripts with our API key into the index.html file that host's our main app.

This way it'll be able to interact with all your views.

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

<head>
  <meta charset="utf-8">
  <title>Angular Snipcart</title>
  <base href="/">

  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>

<body>
  <app-root></app-root>
</body>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.2.2/jquery.min.js"></script>
<script src="https://cdn.snipcart.com/scripts/2.0/snipcart.js" data-api-key="MzMxN2Y0ODMtOWNhMy00YzUzLWFiNTYtZjMwZTRkZDcxYzM4" id="snipcart"></script>
<link href="https://cdn.snipcart.com/themes/2.0/base/snipcart.min.css" rel="stylesheet" type="text/css" />

</html>

In the live demo, i've also edited several templates to aid the styling of each component.

I'll leave this part up to you since it's not the primary focus of the guide.

7. Using Angular Universal for SEO

At last, let's make our app SEO friendly using SSR.

In this demo, I'll make use of a Node.js Express server. Keep in mind that it's possible to add Angular Universal on any server as long as it can interact with Angular's renderModuleFactory function, but the configuration will most likely be different than the one demonstrated in this post.

7.1 Installing Angular Universal

To get started, let's add the necessary tooling to your development environment:

npm install --save @angular/platform-server @nguniversal/module-map-ngfactory-loader ts-loader @nguniversal/express-engine

7.2 Editing the client app

Now that we have all the tools necessary let's edit the client-side code to allow the transition between the server-side rendered page and the client side app. In app.module.ts replace the BrowserModule import in the @NgModule() decorator with the following code:

BrowserModule.withServerTransition({ appId: 'my-app' }),

Since the app is built into the server-side code and the client-side code, you'll need two output paths. Let's start by specifying the output path for the browser. To do so, edit the outputPath in the angular.json.

"architect": {
  "build": {
    "builder": "@angular-devkit/build-angular:browser",
    "options": {
      "outputPath": "dist/browser",
...

7.3 Setting up the server

Now that you've made the necessary modification in the client let's edit the code for the server.

Create app.server.module.ts in the src/app directory.

import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    ModuleMapLoaderModule
  ],
  bootstrap: [ AppComponent ],
})
export class AppServerModule {}

Generate a main.server.ts file in the src/ directory that exports AppServerModule, which will act as the entry point of your server.

export { AppServerModule } from './app/app.server.module';

Also, make a server.ts file in your app's root directory. This file contains the code of the Express server. It'll listen to the incoming request, serve requested asset and render the HTML pages by calling renderModuleFactory (wrapped by ngExpressEngine).

// These are important and needed before anything else
import 'zone.js/dist/zone-node';
import 'reflect-metadata';

import { enableProdMode } from '@angular/core';

import * as express from 'express';
import { join } from 'path';

// Faster server renders w/ Prod mode (dev mode never needed)
enableProdMode();

// Express server
const app = express();

const PORT = process.env.PORT || 4000;
const DIST_FOLDER = join(process.cwd(), 'dist');

// * NOTE :: leave this as require() since this file is built Dynamically from webpack
const { AppServerModuleNgFactory, LAZY_MODULE_MAP } = require('./dist/server/main');

// Express Engine
import { ngExpressEngine } from '@nguniversal/express-engine';
// Import module map for lazy loading
import { provideModuleMap } from '@nguniversal/module-map-ngfactory-loader';

app.engine('html', ngExpressEngine({
  bootstrap: AppServerModuleNgFactory,
  providers: [
    provideModuleMap(LAZY_MODULE_MAP)
  ]
}));

app.set('view engine', 'html');
app.set('views', join(DIST_FOLDER, 'browser'));

// TODO: implement data requests securely
app.get('/api/*', (req, res) => {
  res.status(404).send('data requests are not supported');
});

// Server static files from /browser
app.get('*.*', express.static(join(DIST_FOLDER, 'browser')));

// All regular routes use the Universal engine
app.get('*', (req, res) => {
  res.render('index', { req });
});

// Start up the Node server
app.listen(PORT, () => {
  console.log(`Node server listening on http://localhost:${PORT}`);
});

Now that you've set up the server, you'll need to add and update configuration files. Create tsconfig.server.json file in the src directory.

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "../out-tsc/app",
    "baseUrl": "./",
    "module": "commonjs",
    "types": []
  },
  "exclude": [
    "test.ts",
    "**/*.spec.ts"
  ],
  "angularCompilerOptions": {
    "entryModule": "app/app.server.module#AppServerModule"
  }
}

Set up a webpack.server.config.js file in the app's root directory with the following code:

const path = require('path');
const webpack = require('webpack');

module.exports = {
  entry: { server: './server.ts' },
  resolve: { extensions: ['.js', '.ts'] },
  target: 'node',
  mode: 'none',
  // this makes sure we include node_modules and other 3rd party libraries
  externals: [/node_modules/],
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js'
  },
  module: {
    rules: [{ test: /\.ts$/, loader: 'ts-loader' }]
  },
  plugins: [
    // Temporary Fix for issue: https://github.com/angular/angular/issues/11580
    // for 'WARNING Critical dependency: the request of a dependency is an expression'
    new webpack.ContextReplacementPlugin(
      /(.+)?angular(\\|\/)core(.+)?/,
      path.join(__dirname, 'src'), // location of your src
      {} // a map of your routes
    ),
    new webpack.ContextReplacementPlugin(
      /(.+)?express(\\|\/)(.+)?/,
      path.join(__dirname, 'src'),
      {}
    )
  ]
};

Now update the Angular CLI configuration and set the output path of the server build by adding the following code to angular.json:

"architect": {
  ...
  "server": {
    "builder": "@angular-devkit/build-angular:server",
    "options": {
      "outputPath": "dist/server",
      "main": "src/main.server.ts",
      "tsConfig": "src/tsconfig.server.json"
    }
  }
}

Finally, add the build and serve commands to the scripts section of package.json.

This way you'll be able to keep ng serve for normal client-side rendering and use npm run build:ssr && npm run serve:ssr to use server-side rendering with Universal.

"scripts": {
    ...
    "build:ssr": "npm run build:client-and-server-bundles && npm run webpack:server",
    "serve:ssr": "node dist/server",
    "build:client-and-server-bundles": "ng build --prod && ng run my-app:server",
    "webpack:server": "webpack --config webpack.server.config.js --progress --colors"
}

7.4 Building and running the app

Now that everything is setup, you should be good to go! Build the app and start the server.

npm run build:ssr && npm run serve:ssr

GitHub repo & live demo

See GitHub repo here

See live demo here

Closing thoughts

All in all, building this demo with Angular was an enjoyable experience. Creating a project and grasping the overall idea and concepts of Angular was easier than I thought—it was my first time with that framework! I can definitively see how the approach can be helpful for a big team.

However, incorporating Universal to my project was more difficult than I anticipated; it's really easy to get lost in all the configuration files!

It took me a little bit less than two days to build this demo, including initial reading and bug fixing. Angular's documentation was complete and easy to follow as every piece of code was thoroughly explained.

If I wanted to push this example further, I could have retrieved the products from an API rather than mocking them in the app and made use of services and dependency injection as explained in the official documentation to mimic a more real-life scenario.

Hope this helps you get your Angular SEO right! :)


If you've enjoyed this post, please take a second to share it on Twitter. Got comments, questions? Hit the section below!

About the author

Michael Poirier-Ginter
Developer

Michael has been programming for over 4 years and has recently obtained a Web Development college degree in Québec City. A full stack developer, his go-to technologies are React and Go, but he's also efficient with JavaScript, Vue.js, & .NET. Michael's been known to kick serious a** at CS:GO.

Follow him on LinkedIn.

Using Node.js Express to Quickly Build a GraphQL Server

Read next from Michael
View more

36 000+ geeks are getting our monthly newsletter: join them!