SEO in Angular with SSR - Part I
Ayyash
Posted on March 22, 2022
Today I'm putting together a service that will handle my SEO tags, meta tags, page titles for both Angular SSR and SPA. (Too many acronyms! Server side rendering and single page application - meaning, client side rendering). This is done for content based websites, that may or may not be static (like in a JAM Stack).
Here is the preferred outcome:
Follow along: StackBlitz
The required HTML tags
The following tags should be rendered for every page.
<title>Page title - site title</title>
<!-- open graph -->
<meta property="og:site_name" content="Sekrab Garage">
<meta property="og.type" content="website">
<meta property="og:url" content="pageUrl"/>
<meta name="description" property="og:description" content="description is optional">
<meta name="title" property="og:title" content="Page title">
<meta name="image" property="og:image" content="imageurl">
<!-- twitter related -->
<meta property="twitter:site" content="@sekrabbin">
<meta property="twitter:card" content="summary_large_image"/>
<meta preoprty="twitter:creator" content="@sekrabbin">
<meta property="twitter:image" content="imageurl">
<meta property="twitter:title" content="title">
<meta property="twitter:description" content="description">
<!-- general and for compatibility purposes -->
<meta name="author" content="Ayyash">
<!-- cononical, if you have multiple languages, point to default -->
<link rel="canonical" href="https://elmota.com"/>
<!-- alternate links, languages -->
<link rel="alternate" hreflang="ar-jo" href="ar link">
<meta property="og:locale" content="en_GB" />
The fixed tags may be added directly to the index.html, but let's not do that. Let's organize our meta tags in one place, to have better control.
We'll create a service, provided in the root, injected in root component. Then we need a way to update tags for different routes. So eventually, we need an "Add tags" and "Update tags" public methods. Using the two services provided by Angular: Meta and Title.
@Injectable({
providedIn: 'root'
})
export class SeoService {
// inject title and meta from @angular/platform-browser
constructor(
private title: Title,
private meta: Meta
) {
// in constructor, need to add fixed tags only
}
AddTags() {
// TODO: implement
}
UpdateTags() {
// TODO: implement
}
}
We also need the DOCUMENT injection token to append the link. The service now looks like this
@Injectable({
providedIn: 'root',
})
export class SeoService {
constructor(
private title: Title,
private meta: Meta,
@Inject(DOCUMENT) private doc: Document
) {}
AddTags() {
const tags = [
{ property: 'og:site_name', content: 'Sekrab Garage' },
{ property: 'og.type', content: 'website' },
{ property: 'og:url', content: 'pageUrl' },
{ property: 'twitter:site', content: '@sekrabbin' },
{ property: 'twitter:card', content: 'summary_large_image' },
{ property: 'twitter:creator', content: '@sekrabbin' },
{ property: 'twitter:image', content: 'imageurl' },
{ property: 'twitter:title', content: '[title]' },
{ property: 'twitter:description', content: '[description]' },
{ property: 'og:locale', content: 'en_GB' },
{
name: 'description',
property: 'og:description',
content: '[description]',
},
{ name: 'title', property: 'og:title', content: '[title]' },
{ name: 'image', property: 'og:image', content: 'imageurl' },
{ name: 'author', content: 'Ayyash' },
];
// add tags
this.meta.addTags(tags);
// add title
this.title.setTitle('[Title] - Sekrab Garage');
// add canonical and alternate links
this.createCanonicalLink();
this.createAlternateLink();
}
private createAlternateLink() {
// append alternate link to body, TODO: url and hreflang
const _link = this.doc.createElement('link');
_link.setAttribute('rel', 'alternate');
_link.setAttribute('hreflang', 'en');
_link.setAttribute('href', '[url]');
this.doc.head.appendChild(_link);
}
private createCanonicalLink() {
// append canonical to body, TODO: url
const _canonicalLink = this.doc.createElement('link');
_canonicalLink.setAttribute('rel', 'canonical');
_canonicalLink.setAttribute('href', '[url]');
this.doc.head.appendChild(_canonicalLink);
}
UpdateTags() {
// TOOD: find out what we need to update
}
}
Not all meta tags need to be updated, so those that do not get updated, we shall inject in service constructor. But before I do that, I want to place the tags outside my service, will think about where to place them later. For now, I want to create two arrays, one for fixedTags:
// outside service class
const tags = [
{ property: "og:url", content: "pageUrl" },
{ property: "twitter:image", content: "imageurl" },
{ property: "twitter:title", content: "[title]" },
{ property: "twitter:description", content: "[description]" },
{ name: "description", property: "og:description", content: "[description]" },
{ name: "title", property: "og:title", content: "[title]" },
{ name: "image", property: "og:image", content: "imageurl" }
]
const fixedTags = [
{ property: "og:site_name", content: "Sekrab Garage", dataAttr:'ayyash' },
{ property: "og.type", content: "website" },
{ property: "twitter:site", content: "@sekrabbin" },
{ property: "twitter:card", content: "summary_large_image" },
{ property: "twitter:creator", content: "@sekrabbin" },
{ property: "og:locale", content: "en_GB" },
{ name: "author", content: "Ayyash" }
]
The other end
The simplistic way to implement SEO, goes like this: in every route, after fetching details from server, update title, description, image... etc.
@Component({
templateUrl: './view.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectViewComponent implements OnInit {
project$: Observable<any>;
constructor(private route: ActivatedRoute,
private projectService: ProjectService,
private seoService: SeoService) {
}
ngOnInit(): void {
this.project$ = this.route.paramMap.pipe(
switchMap(params => {
// get project from service by params
return this.projectService.GetProject(params.get('id'));
}),
tap(project => {
// I want to get to this
this.seoService.UpdateTags({
// some pages don't have it from server
title: project.title,
// optional
description: project.description,
// out of context in some pages
url: this.route.snapshot.url,
// may not be known
image: project.image
});
})
)
}
}
Passing params doesn't cut it for me: some pages will not have an image, like a listing page, others may have an image or a title that is not fed by the server. Some pages may include pagination information. The url is a piece of its own work, since not all components depend on route. What I want, is a central place to take care of all the bits, something like this
this.seoService.setProject(project)
ngOnInit(): void {
this.project$ = this.route.paramMap.pipe(
switchMap(params => {
// get project from service by params
return this.projectService.GetProject(params.get('id'));
}),
// map or tap
tap(project => {
// do magic away from here
if (project) {
this.seoService.setProject(project);
}
})
);
}
The magic is in the SEO service:
setProject(project: IProject) {
// set title
const title = `${project.title} - Sekrab Garage`;
this.title.setTitle(title);
this.meta.updateTag({ property: 'og:title', content: title });
this.meta.updateTag({ property: 'twitter:title', content: title});
// set url, from doc injection token (next week we'll cover url in details)
this.meta.updateTag({ property: 'og:url', content: this.doc.URL });
// set description
this.meta.updateTag({ name: 'description', property: 'og:description', content: project.description });
// set image
this.meta.updateTag({ name: 'image', property: 'og:image', content: project.image });
this.meta.updateTag({ property: "twitter:image", content: project.image});
}
This will be a pattern of use, so let me create separate methods for setting the meta tags.
setProject(project: any) {
// set title
this.setTitle(project.title);
// set url
this.setUrl();
// set description
this.setDescription(project.description);
// set image
this.setImage(project.image);
}
private setTitle(title: string) {
const _title = `${ title } - Sekrab Garage`;
this.title.setTitle(_title);
this.meta.updateTag({ name: 'title', property: 'og:title', content: _title });
this.meta.updateTag({ property: 'twitter:title', content: _title });
}
private setDescription(description: string) {
this.meta.updateTag({ name: 'description', property: 'og:description', content: description });
}
private setImage(imageUrl: string) {
this.meta.updateTag({ name: 'image', property: 'og:image', content: imageUrl });
this.meta.updateTag({ property: "twitter:image", content: imageUrl });
}
private setUrl() {
// next week we'll dive into other links
this.meta.updateTag({ property: 'og:url', content: this.doc.URL });
}
Listings page
As for the project list, today it's quite simple, but in the future, this will be a search results page. The outcome needed is a bit smarter than a simple "list of projects".** For example, in a restaurant lookup:**
Title: 345 Restaurants, Chinese Food in San Francisco
Description: Found 345 Restaurants of Chinese food, with delivery, in San Francisco
Note: Don't overkill it, you will be penalized and de-ranked by Google.
The image is also unknown, we can either fall back to the default, or look up a category specific image. I want to be ready for search results:
setSearchResults(total: number, category?: string) {
// Title: 34 projects in Turtles.
// Desc: Found 34 projects categorized under Turtles.
// TODO: localization and transalation...
this.setTitle(`${ total } projects in ${ category }`);
this.setDescription(`Found ${ total } projects categorized under ${ category }`);
this.setUrl();
this.setImage(); // rewrite service to allow null
}
private setImage(imageUrl?: string) {
// prepare image, either passed or defaultImage
// TODO: move defaultImage to config
const _imageUrl = imageUrl || defaultImage;
this.meta.updateTag({ name: 'image', property: 'og:image', content: _imageUrl });
this.meta.updateTag({ property: 'twitter:image', content: _imageUrl });
}
Structuring Title
Title consists of the following parts:
project title, extra info - Site name
The first part is driven by server. But some pages may be static, like "contact us", "Register" or "Page not found." The second part is very contextual, in some apps, like a restaurants finder app, the better SEO is to add extra information about the restaurant like this
Turtle Restaurant, 5 stars in San Francisco - Site name
In our simple project, the category is the only extra information:
setProject(project: IProject) {
// set title
this.setTitle(`${project.title}, in ${project.category}`);
// ... the rest
}
Static page titles using route data
Instead of calling the SEO setter in every component, for static pages I am going to utilize the root app.component
constructor, and the routes
themselves. Showing, not telling:
In a route definition
{
path: 'contact',
component: ProjectContactComponent,
data: {
// add an optional parameter. TODO: translation
title: 'Contact us about a project',
},
}
In root app.component
, watch event changes, and filter out NavigationEnd
events
export class AppComponent {
constructor(
private router: Router,
private activatedRoute: ActivatedRoute,
private seoService: SeoService
) {
this.router.events
.pipe(filter((e) => e instanceof NavigationEnd))
.subscribe((event) => {
// get the route, right from the root child
// this allows a title to be set at any level
// but for this to work, the routing module should be set with paramsInheritanceStrategy=always
let route = this.activatedRoute.snapshot;
while (route.firstChild) {
route = route.firstChild;
}
// create a function with a fallback title
this.seoService.setPageTitle(route.data?.title);
});
}
}
In SeoService:
setPageTitle(title: string) {
// set to title if found, else fall back to default
this.setTitle(title || 'Welcome to Turtles and Lizards');
}
For the title to be fetched at any level of routing, we need to adjust the root routing module to read at any level (paramsInheritanceStrategy), the title value fetched will be the deepest child in the route targeted, that has a title value set, regardless of how shallow it is (it could be the root).
@NgModule({
imports: [
RouterModule.forRoot(routes, {
// this is important if you are going to use "data:title" from any level
paramsInheritanceStrategy: 'always',
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}
This also fixes another issue. Which is taking care of all routes by default. If we don't do a default fallback, the titles may linger too long across multiple navigations.
Side note about sequence of events
Since we are setting title from multiple locations, keep an eye on which occurs last, is it what you intended? Since feature components usually involve API fetching, they are guaranteed to be last, but if you set a constant page title, know which happens first, is it NavigationEnd, componet constructor, or OnInit?
Refactor
Time to put the small bits together in one place. We need to move "fixed tags," "defaults," and constant strings, into a nicer place.
Side note: Localization and translation
I am using a resources class to keep my strings ready for translation, but you probably use i18n package of Angular, and I forgive you, you should localize all strings using that package.
// Config.ts
export const Config = {
Seo: {
tags: [
{ property: 'og:site_name', content: 'Sekrab Garage' },
{ property: 'og.type', content: 'website' },
{ property: 'twitter:site', content: '@sekrabbin' },
{ property: 'twitter:card', content: 'summary_large_image' },
{ property: 'twitter:creator', content: '@sekrabbin' },
{ property: 'og:locale', content: 'en_GB' },
{ name: 'author', content: 'Ayyash' }
],
defaultImage: 'http://garage.sekrab.com/assets/images/sekrab0813.jpg'
}
}
// in SEO service, use Config.Seo.tags and Config.Seo.defaultImage
Putting the strings together in a resources file, remember to translate later. The end result should look like this:
this.setTitle(SomeRes[title] || SomeRes.DEFAULT_PAGE_TITLE);
And for formatted titles, a way to replace simple strings with actual values, like this:
this.setTitle(SomeRes.PROJECT_TITLE.replace('$0',project.title).replace('$1',project.description));
So first, the strings, and let's group them together so we can find them faster:
// A resources.ts file, need to be localized
export const RES = {
SITE_NAME: 'Sekrab Garage',
DEFAULT_PAGE_TITLE: 'Welcome to Turtles and Lizards',
// group static titles together
PAGE_TITLES: {
NOT_FOUND: 'Page no longer exists',
ERROR: 'Oh oh! Something went wrong.',
PROJECT_CONTACT: 'Contact us about a project',
HOME: 'Homepage',
},
// group other formatted strings together
SEO_CONTENT: {
PROJECT_TITLE: '$0, in $1',
PROJECT_RESULTS_TITLE: '$0 projects in $1',
PROJECT_RESULTS_DESC: 'Found $0 projects categorized under $1',
}
};
The route data now holds "key" instead of exact title:
// the project route
{
path: 'contact',
component: ProjectContactComponent,
data: {
title: 'PROJECT_CONTACT', // this is a key
},
},
And one more thing we can leverage, JavaScript Replace
function:
// take a string with $0 $1 ... etc, and replace with arguments passed
export const toFormat = (s:string, ...args: any) => {
const regExp = /\$(\d+)/gi;
// match $1 $2 ...
return s.replace(regExp, (match, index) => {
return args[index] ? args[index] : match;
});
}
Now back to our SEO service
// the changes in the SEOService are:
private setTitle(title: string) {
// change this:
// const _title = `${title} - Sekrab Garage`;
const _title = `${ title } - ${RES.SITE_NAME}`;
// ... rest
}
setPageTitle(title: string) {
// this
// this.setTitle(title || 'Welcome to Turtles and Lizards');
this.setTitle(RES.PAGE_TITLES[title] || RES.DEFAULT_PAGE_TITLE);
}
setProject(project: any) {
// this
// this.setTitle(`${project.title}, in ${project.category}`);
this.setTitle(
toFormat(RES.SEO_CONTENT.PROJECT_TITLE, project.title, project.category)
);
// ...rest
}
setSearchResults(total: number, category?: string) {
// these
// this.setTitle(`${total} projects in ${category}`);
// this.setDescription(
// `Found ${total} projects categorized under ${category}`
// );
this.setTitle(
toFormat(RES.SEO_CONTENT.PROJECT_RESULTS_TITLE, total, category)
);
this.setDescription(
toFormat(RES.SEO_CONTENT.PROJECT_RESULTS_DESC, total, category)
);
// ... rest
}
To translate, we now touch one file. Adding a new feature entails a new method, to customize title and description, and optionally image.
Next...
Links in meta tags are eithe document url, canonical links, and alternate links. We will dive into it next week. Thanks for tuning in. Let me know in the comments if you have any questions.
RESOURCES
Posted on March 22, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.