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:
Building a prerendered Vue.js app
Applying React SEO with Next.js.
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:
Setting up an Angular project.
Creating Angular components.
Enabling e-commerce functionality on our SPA.
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:
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.
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
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!