SEO in Angular with SSR - Part I

ayyash

Ayyash

Posted on March 22, 2022

SEO in Angular with SSR - Part I

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" />
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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" }
]
Enter fullscreen mode Exit fullscreen mode

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 
                });

            })
        )
    }   
}
Enter fullscreen mode Exit fullscreen mode

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);
          }
       })
    );
}
Enter fullscreen mode Exit fullscreen mode

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});
  }
Enter fullscreen mode Exit fullscreen mode

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 });

}
Enter fullscreen mode Exit fullscreen mode

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 });

    }
Enter fullscreen mode Exit fullscreen mode

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
 }
Enter fullscreen mode Exit fullscreen mode

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',
    },
  }
Enter fullscreen mode Exit fullscreen mode

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);
      });
  }
}
Enter fullscreen mode Exit fullscreen mode

In SeoService:

 setPageTitle(title: string) {
    // set to title if found, else fall back to default
    this.setTitle(title || 'Welcome to Turtles and Lizards');
  }
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
  }
};
Enter fullscreen mode Exit fullscreen mode

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
    },
  },
Enter fullscreen mode Exit fullscreen mode

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;
    });
}
Enter fullscreen mode Exit fullscreen mode

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
  }
Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
ayyash
Ayyash

Posted on March 22, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

SEO in Angular with SSR - Part I
angular SEO in Angular with SSR - Part I

March 22, 2022