Outputting JSON-LD with Angular Universal

Posted on

Outputting JSON-LD with Angular Universal

Target audience

This article and guide on generating JSON-LD with Angular Universal is targeted to developers that want to generate the JSON that represents the Linked Data object and inject it correctly in the HTML that gets generated on the server. We will re-use and explain some techniques that the TransferState key-value cache service uses to transfer server-state to the application on the client side.

First of all; What is JSON-LD?

JSON-LD is a lightweight Linked Data format. It is easy for humans and machines to read and write. It is based on the already successful JSON format and provides a way to help JSON data interoperate at Web-scale.

Linked Data empowers people that publish and use information on the Web. It is a way to create a network of standards-based, machine-readable data across Web sites. It allows an application to start at one piece of Linked Data, and follow embedded links to other pieces of Linked Data that are hosted on different sites across the Web.

These 2 definitions of JSON-LD and Linked Data were simply taken from the website of JSON-LD. This article does not go deep into the full specification of JSON-LD and it does not teach you how to properly generate your Linked Data structures. The tools of the JSON-ld website, like for example the playground, give you all you need to validate your Linked Data structures.

A basic example

What you can see below is a typical example of a JSON-LD data object, injected into the HTML. It describes a LocalBusiness by following its definition and providing the properties that identifiy the entity. For example, the address, it's geolocation, opening hours etc are given. As you can see the address has it's own type PostalAddress. Most of the types can specificy an external link and this is how data gets interweaved on the same website or resource and even on other websites.

json-ld-example.html (source)

<script type="application/ld+json">
{
  "@context": "http://schema.org",
  "@type": "LocalBusiness",
  "address": {
    "@type": "PostalAddress",
    "addressLocality": "Manhattan",
    "addressRegion": "NY",
    "postalCode":"10036",
    "streetAddress": "400 Broadway"
  },
  "description": "This is your business description.",
  "name": "Craig's Car Repair",
  "telephone": "555-111-2345",
  "openingHours": "Mo,Tu,We,Th,Fr 09:00-17:00",
  "geo": {
    "@type": "GeoCoordinates",
    "latitude": "20.75",
    "longitude": "13.98"
  },
  "sameAs" : [ "http://www.facebook.com/example",
    "http://www.twitter.com/example",
    "http://plus.google.com/example"]
}
</script>

Why should you care about JSON-LD?

You should definitely care about JSON-LD for several reasons:

  • It's simpler to generate, more structured and easier to read than Microdata
  • It links or allows your data to be linked with other entities / data subjects on the world-wide web

In general; it ads a lot more semantic value to your HTML markup by providing context to what you show on the page and makes this context machine-readable, allowing it to be parsed more easily by search engines and other crawlers on the web.

Generating your JSON Linked Data structure

In the simple case of a blog or not too complex website, generating your JSON Linked Data can be done using the same base data you use to set your meta tags for social sharing and SEO. Your most important concern is building the correct structure. How your structure looks is completely dependent on your usecase. As a basic example we will use this website, and more specifically the about page.

Using the router we first define the correct SEO data associated with our route. Just like we did before, setting the correct social share meta tags.

about-routing.module.ts

RouterModule.forChild([{
    path: '',
    component: AboutComponent,
    data: {
      seo: {
        title: `About Sam - ${environment.seo.title}`,
        description: `I'm a 30 year old software engineer living in Belgium.`,
        shareImg: '/assets/share/about.png',
      }
    }
}])

Using a service we can subscribe to route changes to extract this data and pass the data to a service to parse the JSON-LD object.

route-helper.service.ts

@Injectable({
    providedIn: 'root',
})
export class RouteHelper {

  constructor(
    private readonly router: Router,
    private readonly activatedRoute: ActivatedRoute,
    private readonly jsonLdService: JsonLdService
  ) {
    this.setupRouting();
  }

  private setupRouting() {
    this.router.events.pipe(
      filter(event => event instanceof NavigationEnd),
      map(() => this.activatedRoute),
      map(route => {
        while (route.firstChild) {
          route = route.firstChild;
        }
        return route;
      }),
      filter(route => route.outlet === 'primary')
    ).subscribe((route: ActivatedRoute) => {
      const seo = route.snapshot.data['seo'];
      // generate your JSON-LD object here
      const jsonLd = {
        name: seo.title,
        url: environment.url + this.router.routerState.snapshot.url,
      };
      this.jsonLdService.setData('Website', jsonLd);
    });
  }

}

The JsonLdService injected above is a simple service that caches and updates the data structure we need to output. A basic implementation of this service can be as follows, where you as a developer are still in charge of correctly structuring your object.

json-ld.service.ts

@Injectable({
  providedIn: 'root',
})
export class JsonLdService {

  private jsonLd: any = {};

  setData(type: string, rawData: any) {
    this.jsonLd = this.getObject(type, rawData);
  }

  getObject(type: string, rawData?: any) {
    let object = {
      '@context': 'http://schema.org',
      '@type': type,
    };
    if (rawData) {
      object = Object.assign({}, object, rawData);
    }
    return object;
  }

  toJson() {
    return JSON.stringify(this.jsonLd);
  }
}

Using the approach explained above our current JSON-LD data-object in memory will look like this:

json output

{
    "@context": "http://schema.org",
    "@type": "Website",
    "name": "About Sam - Sam Vloeberghs - Freelance Webdeveloper & Software Engineer",
    "url": "https://samvloeberghs.be/about"
}

This basic example is what we wanted to achieve. The next step is getting this object outputted in our static HTML.

Injecting the JSON in the static HTML

Getting inspiration in the Angular source code

Injecting the JSON-LD data object in the DOM is only really required when we generate the HTML on the server. To get to this result I looked into the BrowserTransferStateModule exposed via the @angular/platform-browser module.

Essentialy this module, and more specifically, the TransferState service it provides, does the same thing we want to achieve. It caches data, the result from for example HTTP calls, in an object and holds it in memory during the application lifecycle. Apart from the HTTP calls, that's exactly what our JsonLdService does.

The server counter part, ServerTransferStateModule exposed via the @angular/platform-server, serializes the data and injects it in the DOM before generating the HTML on the server and sending it back over the wire to the browser. Again, that is exactly what we want to achieve. This is achieved by providing an extra factory function to the BEFORE_APP_SERIALIZED token. Right after the application becomes stable all factories attached to the BEFORE_APP_SERIALIZED are executed.

server: transfer_state.ts (full source code here)

export function serializeTransferStateFactory(
  doc: Document, appId: string, transferStore: TransferState
) {
  return () => {
    const script = doc.createElement('script');
    script.id = appId + '-state';
    script.setAttribute('type', 'application/json');
    script.textContent = escapeHtml(transferStore.toJson());
    doc.body.appendChild(script);
  };
}
@NgModule({
  providers: [
    TransferState,
    {
      provide: BEFORE_APP_SERIALIZED,
      useFactory: serializeTransferStateFactory,
      deps: [DOCUMENT, APP_ID, TransferState],
      multi: true,
    }
  ]
})
export class ServerTransferStateModule {
}

When the Angular application bootstraps in the browser, the TransferState service picks up the serialized state in the static HTML and unserializes it, making it available as a direct cache. This way we avoid that the application makes a similar request for the same data to the server, for which the server-side-rendered version already did the call. We don't need this part, but it's good to know.

browser: transfer_state.ts (full source code here)

export function initTransferState(
  doc: Document, appId: string
) {
  const script = doc.getElementById(appId + '-state');
  let initialState = {};
  if (script && script.textContent) {
    try {
      initialState = JSON.parse(unescapeHtml(script.textContent));
    } catch (e) {
      console.warn('Exception while restoring TransferState for app ' + appId, e);
    }
  }
  return TransferState.init(initialState);
}
@NgModule({
  providers: [{
    provide: TransferState,
    useFactory: initTransferState,
    deps: [DOCUMENT, APP_ID]
  }s],
})
export class BrowserTransferStateModule {
}

Our simple BrowserJsonLdModule and ServerJsonLdModule

Following the concepts and ideas we learned from the ServerTransferStateModule and the BrowserTransferStateModule we create our own ServerJsonLdModule and BrowserJsonLdModule. The only small differences are limited to changing the type of the <script> element and injecting it in the <head> instead of right before the </body> tag. We also don't need to pickup any state from the static HTML, as is done in the BrowserTransferStateModule.

json-ld.module.ts

import { NgModule } from '@angular/core';
import { JsonLdService } from './json-ld.service';

@NgModule({
  providers: [
    JsonLdService,
  ]
})
export class BrowserJsonLdModule {
}

json-ld.server.module.ts

import { NgModule } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { BEFORE_APP_SERIALIZED } from '@angular/platform-server';
import { JsonLdService } from './json-ld.service';

export function serializeJsonLdFactory(doc: Document, jsonLdService: JsonLdService) {
  const serializeAndInject = function () {
    const script = doc.createElement('script');
    script.setAttribute('type', 'application/ld+json');
    script.textContent = jsonLdService.toJson();
    doc.head.appendChild(script);
  };
  return serializeAndInject;
}

@NgModule({
  providers: [
    JsonLdService, {
    provide: BEFORE_APP_SERIALIZED,
      useFactory: serializeJsonLdFactory,
      deps: [DOCUMENT, JsonLdService],
      multi: true,
    },
  ],
})
export class ServerJsonLdModule {
}

Differentiate execution on the server

To differentiate the execution of our application on the server versus on the browser, in Angular Universal we typically create a new server module app.server.module.ts next to app.module.ts that will directly import the AppModule exported by app.module.ts. This version of the application is used in the HTML renderer configured at the server side, in most cases as an rendering engine.

If we go back to the example of the TransferState service, we see that the app.server.module.ts is importing the ServerTransferStateModule. This will overwrite the provider of the JsonLdService imported in the app.module.ts and provide the extra serialize functionality, next to also providing the TransferState service.

app.module.ts

@NgModule({
  declarations: [
    AppComponent,
  ],
  imports: [
    BrowserModule.withServerTransition({appId: 'samvloeberghs'}),
    BrowserTransferStateModule,
    BrowserJsonLdModule,
    CommonModule,
    AppRoutingModule
  ],
  exports: [
    AppComponent,
  ],
  bootstrap: [AppComponent],
})
export class AppModule {
}

app.server.module.ts

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    ModuleMapLoaderModule,
    ServerTransferStateModule,
    ServerJsonLdModule,
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule {
}

We do the same thing with our BrowserJsonLdModule and ServerJsonLdModule. The browser module has to be imported in app.module.ts while the server module has to be imported in app.server.module.ts.

Conclusion

Generating JSON-LD using Angular Universal is pretty straight-forward. By borrowing concepts from the Angular source code we can create our own JsonLd modules and services that enable us to output JSON-LD in the statically generated HTML on the server. The result of this code can be found live on this website, just look at the source and do a hard refresh. Why a hard refresh you might ask? Because of the service worker caching the default index.html as the app shell.

Further reading

Special thanks to

for reviewing this post and providing valuable and much-appreciated feedback!