Welcome to another post about my Angular Trip, this time i’ll be covering SEO optimization with Angular apps. If you didn’t read my previous post about testing, you can find it here.

Part One: Preparing ground

The source code used in this blog post can be found here. It was generated using Angular CLI to demonstrate how SEO can be improved within an Angular application. Before we dive into the details of the application, let’s do a quick basics tour.

What is SEO ?

Search engine optimization (SEO) is the process of affecting the visibility of a website or a web page in a web search engine’s unpaid results.

wikipedia

In other words, web search engines like Google, Yahoo, … fetch websites metadata using crawlers. Metadata helps search engines understand the content of websites and determine if it matches search queries. The more accurate is the metadata, the better will be the ranking of the website.

However, Angular is a client side framework which means code executes on the client machine. This is an issue for crawlers because they can’t execute javascript, they can only read, therefore can’t interpret the content of the page which inflics a penalty on the website’s visibility.

Angular Universal

If Angular can run on the server, search engines will be able to read the metadata and index its content. Fortunatly, this is exaclty what Angular Universal does. Universal was created to make Angular render on the server for faster initial load and enhanced SEO. Starting Angular 4.3, it was integrated in the official ecosystem. Let’s try to understand how it works.

Part Two: Setting up

In a similar approach to client side, Angular is bootstrapped using an AppServerModule which imports the AppModule and ServerModule.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';
import { AppComponent } from './app/app.component';
import { AppModule } from './app/app.module';
@NgModule({
imports: [
ServerModule,
AppModule
],
bootstrap: [
AppComponent
]
})
export class AppServerModule {}

Notice the ServerModule being imported from the platform-server library, the equivalent of the platform-browser library.

Server side rendering should only be for the first page, therefore it needs to pass on the torch to the normal execution mode of Angular using the withServerTransition method.

1
2
3
4
5
6
7
8
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule.withServerTransition({
appId: 'smart-ang-seo'
}),

The app is now server side ready but it still missing the server. To create it, an easy option is to use Express server.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import 'reflect-metadata';
import 'zone.js/dist/zone-node';
import { platformServer, renderModuleFactory } from '@angular/platform-server'
import { enableProdMode } from '@angular/core'
import { AppServerModuleNgFactory } from '../dist/ngfactory/src/app/app.server.module.ngfactory'
import * as express from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
const PORT = 4000;
enableProdMode();
const app = express();
let template = readFileSync(join(__dirname, '..', 'dist', 'index.html')).toString();
app.engine('html', (_, options, callback) => {
const opts = { document: template, url: options.req.url };
renderModuleFactory(AppServerModuleNgFactory, opts)
.then(html => callback(null, html));
});
app.set('view engine', 'html');
app.set('views', 'src')
app.get('*.*', express.static(join(__dirname, '..', 'dist')));
app.get('*', (req, res) => {
res.render('index', { req });
});
app.listen(PORT, () => {
console.log(`listening on http://localhost:${PORT}!`);
});

Express is going to read the content of the index.html and use the renderModuleFactory method imported from the platform-server module to execute the angular app then return the stream as a reponse to the request.
Notice that the AppServerModuleNgFactory is generated using the angular compiler.
Some adjustments are needed for the compile to work properly:

  • Add server.ts to the exclude array in /src/tsconfig.app.json
1
2
3
4
5
"exclude": [
"server.ts",
"test.ts",
"**/*.spec.ts"
]
  • Add angularCompilerOptions to tsconfig.json
1
2
3
4
"angularCompilerOptions": {
"genDir": "./dist/ngfactory",
"entryModule": "./src/app/app.module#AppModule"
}
  • Finally, use these commands to run the application

    To build the application:

    1
    ng build --prod & ngc

    To run the application:

    1
    ts-node src/server.ts

    Hint: I added an npm task for this:

    1
    npm run universal

To verify that the application can be server side rendered, deactivate javascript execution in your browser then navigate to http://localhost:4000

Metadata

Our pages are finally readable by crawlers so it’s time to add metadata. Meta and Title are provided by the platform-browser library. They are used to provide unique details about the content of the page.

1
2
3
4
5
6
7
8
9
10
11
12
13
export class HomeComponent implements OnInit {
constructor(private meta: Meta, private title: Title) { }
ngOnInit() {
title.setTitle('My Home Page');
meta.addTags([
{ name: 'author', content: 'bms.com' },
{ name: 'description', content: 'this is my home description' },
]);
}
}

Run the app again and you will see meta and title elements in the source code of the page.

Part three: Diving deeper

Because internet trends continually change, we need to update our metadata accordingly to maintain our visibility in search results. Furthermore, sometimes, it’s useful to choose which metadata to return according to the characteristics of the crawler (origin, language, …). For instance, if the crawler defines english as an accept language, it will be interesting to return the english version of the most trending keywords that your website’s content covers.

Although, it might seems like a tricky task, it’s actually not that complicated.

First, we need to retrieve information about the crawler and pass it to our Angular app using Express. In server.ts, modify the engine line to extract the accept-language header value from the incoming request:

1
2
3
4
5
6
7
app.engine('html', (_, options, callback) => {
const opts = { document: template, url: options.req.url };
region.ipAdr = options.req.ip;
region.lang = options.req.headers['accept-language'];
renderModuleFactory(AppServerModuleNgFactory, opts)
.then(html => callback(null, html));
});

and pass it to angular via an endpoint:

1
2
3
4
5
6
7
8
app.set('view engine', 'html');
app.set('views', 'src');
app.get('/region', function (req, res) {
res.send(region);
});
app.get('*.*', express.static(join(__dirname, '..', 'dist')));

Then add a service to handle the choosing of the adequate keywords:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/switchMap';
@Injectable()
export class SeoService {
constructor(private http: Http) { }
getKeywords(): Observable<string> {
return this.getRegion()
.do(res => console.log(res))
.switchMap(res => this.getKeywordsByRegion(res[0]))
.do(res => console.log(res))
}
getKeywordsByRegion(arg: string): Observable<string> {
if (arg === 'fr-Fr') {
return this.http.get('https://api-adresse.data.gouv.fr/search/?q=eiffel tower')
.map((res: Response) => res.json() as any)
.map(res => res.query);
} else {
return this.http.get('https://api-adresse.data.gouv.fr/search/?q=downing street')
.map((res: Response) => res.json() as any)
.map(res => res.query);
}
}
getRegion(): Observable<string[]> {
return this.http.get('http://localhost:4000/region')
.map((res: Response) => res.json() as any)
.map(res => res.lang.split(';'));
}
}

Finally, use the service inside your components:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export class HomeComponent implements OnInit {
constructor(private meta: Meta, title: Title, private adrService: SeoService) {
title.setTitle('My Home Page');
meta.addTags([
{ name: 'author', content: 'bms.com' },
{ name: 'description', content: 'this is my home description' },
]);
}
ngOnInit() {
this.adrService.getKeywords()
.subscribe(res => this.meta.addTag({ name: 'keywords', content: res }));
}
}

That was an easy way to keep your website up to date without a lot of effort but keep in mind that if your pages contain information irrelevant to your metadata, your website will likely be downgraded by search engines.