Sekrab Garage

Google Search Snippets

SEO in Angular with SSR - Part III

AngularDesign April 6, 22
Series:

SEO Service

In this three-part article, we are putting together a service to handle SEO requirements in an Angular application.
  1. 7 months ago
    SEO in Angular with SSR - Part I
  2. 6 months ago
    SEO in Angular with SSR - Part II
  3. 6 months ago
    SEO in Angular with SSR - Part III
  4. 15 days ago
    Update: Using Angular TitleStrategy

Two weeks ago I started building an SEO service that covers all SEO needs of an Angular App. The last subject to cover is structured data that produces Google Search Snippets.

Google Search displays results in different styles depending on what you feed it. In order to format the result, Google recommends structured data with JSON-LD format.

This article is not about the value of structured data, nor which is the right type to add. It is about how to organize structured data in a service in Angular.

The final result is on StackBlitz

Snippets are hard!

Testing code examples in Google docs, in the Rich Results Testing tool -believe it or not- produces warnings. I have done this before, and getting to all green checkboxes is a waste of effort. So we just try! Keep it simple.

The basics

The main script expected is:

<script type="application/ld+json">
{
  "@context": "http://schema.org/",
  "@type": "type-from-gallery",
  ... props
}
</script>

It can be added anywhere, we will append it to end of body.

The props are specific to each type in the search gallery. It can also have subtypes. For example, a Recipe type can have a review property, which is of type Review.

We can place all types in one @graph property to hold all other types in one script.

@graph is not documented on Google website, I wonder why. It is mentioned on Google Open Source Blog, and testing it on Rich Results Test tool, it works.

The other option is to add each individual item to an array, like this:

<script type="application/ld+json">
[{
  "@context": "http://schema.org/",
  "@type": "type-from-gallery",
  ... props
},
{
  "@context": "http://schema.org/",
  "@type": "type-from-gallery",
  ... props
}]
</script>

The main guideline we need to adhere to is that the snippets must be representative of content viewable to user.

So first, we need to add a script, with a @graph array, once, updatable on reroutes. That sounds like a private member, created in constructor. I'll name it snippet instead of structured data because no one is watching!

export class SeoService {
  private _jsonSnippet: HTMLScriptElement;

  private createJsonSnippet(): HTMLScriptElement {
    const _script = this.doc.createElement('script');
    // set attribute to application/ld+json
    _script.setAttribute('type', 'application/ld+json');

    // append to body and return reference
    this.doc.body.appendChild(_script);
    return _script;
  }

  // add script as soon as possible
  AddTags() {
    // ... 
    // add json-ld
    this._jsonSnippet = this.createJsonSnippet();
  }
}

Google Bot JavaScript content and SSR

A little digging around through the tons of docs on Google website reveals the following:

  • Google bot runs Javascript to load content initially.
  • The bot then finds href proper links
  • The SPA, no matter how SPA'd it is, will be rerun by the bot (good news)
  • The bot waits for the final content before crawling
  • Duplicate scripts on the same page, is not an issue

This means:

  • We can add an empty array on load, and append to it, we don't have to update existing elements, but that would be nicer.
  • We do not have to remove existing snippets on page reroutes, because the bot will reload the page anyway, but for page performance, we might want to empty first.
  • If we implement SSR, duplicating the script on rehydration is not an issue, but it's ugly. So we will target one platform, or check for existing script.

With all of that in mind, we are ready to start adding our schemas.

Logo

Right. Let's start with the simplest one, the Logo. The final result should look like this:

{
  "@type": "Organization",
  "url": "url associated with organization",
  "logo": "logo full url",
  "name": "why is google docs ignoring name?"
}

We do not have to add to every page, only the home page (/). As for updating snippet, we will rewrite textContent property of the script.

// SEO Service
setHome() {
  // update snippet with logo
   const _schema = {
    "@type": "Organization",
    // url is the most basic in our case, it could be less dynamic
    // I am reusing default url, so will refactor this out later
    url: toFormat(Config.Seo.baseUrl, Config.Seo.defaultRegion, Config.Seo.defaultLanguage, ''),
    // logo must be 112px minimum, svg is acceptable
    // add this new key to config.ts
    logo: Config.Seo.logoUrl,
    // I am including name anyway
    "name": RES.SITE_NAME
  }

  // update script
  this.updateJsonSnippet(_schema);
}

private updateJsonSnippet(schema: any) {
  // basic, added the schema to an array
  const _graph = { '@context': 'https://schema.org', '@graph': [schema] };
  // turn into proper JSON 
  this._jsonSnippet.textContent = JSON.stringify(_graph);
}
// adding defaultUrl and siteUrl and refactoring service 
get defaultUrl(): string {
  return toFormat(Config.Seo.baseUrl, Config.Seo.defaultRegion, Config.Seo.defaultLanguage, '');
}
get siteUrl(): string {
  return toFormat(Config.Seo.baseUrl, Config.Basic.region, Config.Basic.language, '');
}

And in HomeComponent

ngOnInit(): void {
  this.seoService.setHome();
}

Moving on to another basic type:

Sitelinks Search Box

The rule is, one search action sitewise, and accepts one single string as query. In a restaurant app for example, this search URL does not work:

/search?category=chinese&price=low&open=true&nonsmoking=true&query=korma&location=sandiego&page=3

The app must handle the simplest query:

/search?query=korma

Of course, every web app has its own purpose, you might want to make your google listing allow users to search for Non smoking by default, because that is your niche. In such case, the URL specified in snippet should include the preset conditions.

The URL itself can have language and region information. I could not find anything that speaks against this, but I saw examples (adobe) that ignore language and region. So I will use the default values.

Assuming we create the functionality of searching by keyword (q), we can add the following to the homepage. The final result looks like this

{
  "@type": "WebSite",
  "url": "https://{{default}}.domain.com/{{default}}",
  "potentialAction": {
    "@type": "SearchAction",
    "target": {
      "@type": "EntryPoint",
      "urlTemplate": "https://{{default}}.domain.com/{{default}}/projects;q={search_term}"
    },
    "query-input": "required name=search_term"
  }
}

Google says: Add this markup only to the homepage, not to any other pages. Righteo Google. In our setHome:

// ... second schema
const _schema2 = {
  '@type': 'Website',
  url: this.defaultUrl,
  potentialAction: {
    '@type': 'SearchAction',
    target: {
      '@type': 'EntryPoint',
      urlTemplate:  this.defaultUrl + '?q={serach_term}',
    },
    'query-input': 'required name=search_term',
  },
};
// oh oh! need a way to append
this.updateJsonSnippet(_schema2);

I choose to append to the @graph collection, because it's easier. Let me rewrite the update with that in mind.

// let's keep track of the objects added
private _graphObjects: any[] = [];

private updateJsonSnippet(schema: any) {
  // first find the graph objects
  const found = this._graphObjects.findIndex(n => n['@type'] === schema['@type']);

  // if found replace, else create a new one
  if (found > -1) {
      this._graphObjects[found] = schema;
  } else {
      this._graphObjects.push(schema);
  }

  const _graph = { '@context': 'https://schema.org', '@graph': this._graphObjects };
  this._jsonSnippet.textContent = JSON.stringify(_graph);
}

With that, we covered the basics. Let's see how much effort is needed for every feature.

Set snippet for feature

Our feature is a Project, which does not have any schema support in Google bot. The closest thing is Article. Let me add a snippet for article that looks like this:

Psst: Don't lose sleep over this, Google docs change, their recommendations change, and the results are never guaranteed. Stay simple, stay healthy.
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": "Project title",
  "image": "Project image",
  "datePublished": "date created",
  "author": [{
      "@type": "Organization",
      "name": "Sekrab Garage",
      "url": "https://www.domain.com/en/"
    }]
}

So in our project, the setProject

setProject(project: IProject) {
  // ...
  this.updateJsonSnippet({
    '@type': 'Article',
    headline: project.title,
    image: project.image,
    datePublished: project.dateCreated,
    author: [{
      '@type': 'Organization',
      name: RES.SITE_NAME,
      url: this.defaultUrl
    }]
  });
}

Another element worth investigating is the BreadcrumbList. It is an ItemList. The first element is a link to the projects list with matching category. Project title as the second element. That too shall appear in project details page. So let's amend the setProject:

setProject(project: IProject) {
    // ...
    this.updateJsonSnippet({
      '@type': 'BreadcrumbList',
      itemListElement: [{
          '@type': 'ListItem',
          position: 1,
          name: project.category.value,
          // the url where users can find the list of projects with matching category
          item: this.siteUrl + 'projects?categories=' + project.category.key
      }, {
          '@type': 'ListItem',
          position: 2,
          name: project.title
      }]
    });
}

And the last bit is the list of projects (articles) in search results

Snippet of a list

This too is an ItemList of the result set. So now when we have a title like this

Top 20 Non smoking cafes in Dubai

And our page contains the list of those 20, the result, as promised, should be a carousel of items. Unless, Google already provided their own featured results. Which is almost all the time!

{
  "@type": "ItemList",
  "itemListElement": [{
    "@type": "ListItem",
    // increasing
    "position": 1,
    // url to result details
    "url": "https://domain.com/projects/32342"
  }]
}

In our SeoService

// change this to accept projects array
setSearchResults(params: IListParams, projects: IProject[]) {
  //...
  // for every element, use params to construct url
  // region.domain.com/language/projects/id
  let i = 1;
  // construct the URL
  const url =this.siteUrl + 'projects/';

  this.updateJsonSnippet({
    '@type': 'ItemList',
    // I need to pass projects 
    itemListElement: projects.map(n => {
      return {
        '@type': 'ListItem',
         url: url + n.id,
        position: i++
      }
    }),
  });
}

Then in the search List component of projects, let me pass projects results

ngOnInit(): void {
  // search results component
  // ...
    // pass projects results
    this.seoService.setSearchResults(param, projects);
}

A little of refactoring

The SeoService could potentially grow massively. In larger projects, handing over the update of the schema to the feature service makes more sense. Because we are accessing the feature's properties. In this app, I chose to break it down to multiple services inheriting the basics from SeoService.

Now that I have multiple services, all provided in root, the constructor will be called multiple times. So everything in constructor needs to check whether something already took place, or not.

Our AddTags function, as it is now with the document.querySelecor already does that. this.meta.addTags by design, avoids duplicates. So we are set. Have a look at the final StackBlitz project.

SSR

Server platforms is a better choice to serve on, since bots understand it, and it does not have to wait for rehydration to get scripts content.

if (environment.production && this.platform.isBrowser) 
// do not add scripts in browser
return;

We can also check for existence of the script and reuse it, like we did previously:

this._jsonSnippet =
  this.doc.querySelector('script[type="application/ld+json"]') ||
  this.createJsonSnippet();

If we do not have SSR implemented, on reroutes, the browser platform will start accumulating scripts in the HTML. That does not affect crawling, but it might affect page performance. Adding emptyJsonSnippet. This should be called before major component reroutes, no need to overuse it.

// SeoService
protected emptyJsonSnippet() {
  // sometimes, in browser platform, we need to empty objects first
  this._graphObjects = [];
}

Unsupported types

Google adds support for new types, as they remove support for experimental ones. The target is types documented on schema.org. If you have types that are not yet supported, you can add them, and follow the schema.org instructions. Having structured data serves other purposes beyond Google search snippets. But one day, those types will be properly supported. Here is an example of an unsupported type:

// not yet supported by Google
return {
  '@type': 'MedicalEntity', 
  url: url + product.key,
  name: product.name,
  description: product.description,
  image: product.image,
  medicineSystem: 'WesternConventional',
  relevantSpecialty: product.specialties ? product.specialties.map(n => n.name).join(', ') : null
};

Criticism

Try this in google search "Nebula Award for Best Novel". The first result looks like this

Google search result example

Now open page, and look for the snippet:

{
  "@context": "https:\/\/schema.org",
  "@type": "Article",
  "name": "Nebula Award for Best Novel",
  "url": "https:\/\/en.wikipedia.org\/wiki\/Nebula_Award_for_Best_Novel",
  "sameAs": "http:\/\/www.wikidata.org\/entity\/Q266012",
  "mainEntity": "http:\/\/www.wikidata.org\/entity\/Q266012",
  "author": {
      "@type": "Organization",
      "name": "Contributors to Wikimedia projects"
  },
  "publisher": {
      "@type": "Organization",
      "name": "Wikimedia Foundation, Inc.",
      "logo": {
          "@type": "ImageObject",
          "url": "https:\/\/www.wikimedia.org\/static\/images\/wmf-hor-googpub.png"
      }
  },
  "datePublished": "2004-01-03T16:06:25Z",
  "dateModified": "2022-04-04T15:53:53Z",
  "image": "https:\/\/upload.wikimedia.org\/wikipedia\/en\/8\/8e\/Nebula_Trophy.jpg",
  "headline": "literary award"
}

Do they match? Not really.

I have researched snippets for a while, and read a lot of criticism of it. The major point against it, is the changing rules. What validates today, does not necessarily validate next year. In addition to that, you can swear on having your snippets in place, and yet Google chooses not to display it as expected. Because what happens in Google, stays in Google. Bottom line? Snippets are okay, but they are vague. Keep them simple and remember:

Google shall find you!

Thank you for reaching the bottom of this post. Let me know if you spot a bug or a butterfly.

  1. 7 months ago
    SEO in Angular with SSR - Part I
  2. 6 months ago
    SEO in Angular with SSR - Part II
  3. 6 months ago
    SEO in Angular with SSR - Part III
  4. 15 days ago
    Update: Using Angular TitleStrategy