SEO in Angular with SSR - Part III
Tags: angular, seo, tutorial, typescript
Published: 2022-04-06 10:11:48

Two weeks ago I started building an [SEO service](https://garage.sekrab.com/posts/seo-in-angular-with-ssr-part-i) 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](https://stackblitz.com/edit/angular-seo-service?file=src/app/services/seo.service.ts)

#### Snippets are hard!
Testing code examples in Google docs, in the [Rich Results Testing](https://search.google.com/test/rich-results) 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:
```html
<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](https://developers.google.com/search/docs/advanced/structured-data/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](https://opensource.googleblog.com/2021/08/schema-dts-turns-1-author-valid-schema-org-JSON-LD-in-typescript.html), and testing it on [Rich Results Test](https://search.google.com/test/rich-results) tool, it works.

The other option is to add each [individual item](https://developers.google.com/search/docs/advanced/structured-data/sd-policies#individual-items) to an array, like this:

```html
<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!
```typescript
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](https://developers.google.com/search/docs/advanced/javascript/javascript-seo-basics#titles-and-snippets) 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](https://www.ilanadavis.com/articles/the-myth-of-duplicate-structured-data), 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](https://developers.google.com/search/docs/advanced/structured-data/logo)
Right. Let's start with the simplest one, the Logo. The final result should look like this:
```json
   {
      "@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.
```typescript
  // 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](https://developers.google.com/search/docs/advanced/structured-data/sitelinks-searchbox) 

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
```json
   {
      "@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`:

```typescript
  // ... 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.
```typescript
  // 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`](https://developers.google.com/search/docs/advanced/structured-data/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.

```json
  {
      "@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`
```typescript
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`](https://developers.google.com/search/docs/advanced/structured-data/breadcrumb). 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`:
```typescript
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`](https://developers.google.com/search/docs/advanced/structured-data/carousel#summary) 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!

```json
{
    "@type": "ItemList",
    "itemListElement": [{
        "@type": "ListItem",
        // increasing
        "position": 1,
        // url to result details
        "url": "https://domain.com/projects/32342"
    }]
}
```
In our `SeoService`
```typescript
// 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
```typescript
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](https://stackblitz.com/edit/angular-seo-service).

### 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. 

```typescript
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](https://garage.sekrab.com/posts/seo-in-angular-with-ssr-part-ii):
```typescript
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.
```typescript
// 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](https://schema.org/docs/full.html). 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:

```typescript
// 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](https://www.google.com/search?q=Nebula+Award+for+Best+Novel)". The first result looks like this

![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1649178434868/LO2y7OviI.png)

Now open page, and look for the snippet:
```json
{
    "@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.

Resources

- [Google snippets](https://developers.google.com/search/docs/advanced/appearance/snippet)
- [Google structured data gallery](https://developers.google.com/search/docs/advanced/structured-data/search-gallery)
- [Walk through](https://codelabs.developers.google.com/codelabs/structured-data/index.html#0)
- [All about featured snippets](https://www.dbswebsite.com/blog/understanding-featured-snippets-in-google-search/)
- [Schema.org types](https://schema.org/docs/full.html)
- [Understanding JavaScript SEO basics](https://developers.google.com/search/docs/advanced/javascript/javascript-seo-basics)
- [Rich Results testing tool](https://search.google.com/test/rich-results)
- [StackBlitz](https://stackblitz.com/edit/angular-seo-service)