DEV Community

Cover image for SEO in Angular with SSR - Part III
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

SEO in Angular with SSR - Part III

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

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

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

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

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

And in HomeComponent

ngOnInit(): void {
  this.seoService.setHome();
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

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

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 = [];
  }
Enter fullscreen mode Exit fullscreen mode

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

Criticism

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

image.png

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

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.

Resources

Discussion (0)