Angular Universal with Dynamic Metadata hosted on AWS Elastic Beanstalk, for Google SEO and for Link Preview to work for sub-pages

In this article I will show you how to convert an Angular SPA application to an Angular Universal application so that you may render your application on the server instead of the normal client-side rendering. Then I will show you how to add dynamic metadata for your routes to help the Google crawlers and enable different link previews for each route, and finally, how to host your Angular Universal application in AWS Elastic Beanstalk.

I recently created an Angular App using Angular 10 for my new website WorkingOnTheMove.com. Part of the site is a blog, and I wanted the blog articles to be crawled and indexed by Google to drive traffic to my site. I also wanted to be able to post a blog article on LinkedIn or Facebook or Twitter with each article having its own image, URL, title, and description on the link preview. This would have been easy if this was just an HTML site or I had used a blog site, but I have future plans for the site that I can most easily accomplish with Angular.

At first, I set it up as a normal SPA, storing the production bundle in an AWS S3 bucket with CloudFront. That’s when I discovered Google was not crawling the site, even when I added dynamic meta data (we will discuss that below). Supposedly Google is better about crawling SPA sites than it used to be, but I did not find that to be the case. Google said that it had discovered the pages and implied it would crawl them, but after 3 weeks I decided it may never crawl them. Also, the dynamic meta data would not work for link preview. Instead, it always used the image, URL, title, and description from my index.html file.

After some research, I decided I needed to use server-side rendering of my Angular application. This resolved the issue with Google crawlers and link preview.

Here is a sample link preview for one of my blog posts as it would show in Facebook, after my move to the server.

In this article I will cover three things that made this finally work:

  • How to convert your Angular SPA app to an Angular Universal app
  • How to add dynamic meta data, which will help the Google crawlers and allow you to specify different meta data for each route for link preview
  • How to host your Angular Universal application in AWS Elastic Beanstalk


Converting to Angular Universal

Adding Angular Universal to your project is easy. Go to your project folder and run:

ng add @nguniversal/express-engine

This will add some files to your project

  • at the src folder level, server.ts, tsconfig.server.json
  • in the src folder, server.ts
  • in the src/app folder, app.server.module.ts

If you want to run your application locally use npm run dev:ssr, a new script created in your package.json file. You can still access it at localhost:4200 like ng serve.

To compile your application for deployment in production, use npm run build:ssr. You can run the package created in your dist rolder using npm run serve:ssr.

Look in your dist folder after running npm run build:ssr and you will now see a browser folder and a server folder.

Add dynamic metadata to your routes

If you are familiar with seo and the Open Graph Protocol, you know that you need some metadata in your index.html file for the Google crawlers and to enable link preview. If you do not care about Google crawling your routes and having different link previews for different routes, then this is all you have to do.

<title>Working on the Move</title>
<meta name="description" content="A guide for remote workers seeking adventure">
<meta property="og:url" content="https://workingonthemove.com" />
<meta property="og:site_name" content="Working on the Move" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Working on the Move" />
<meta property="og:description" content="Helping remote workers become digital nomads by taking their jobs on the road!" />
<meta property="og:image" content="https://workingonthemove-images.s3.us-east-2.amazonaws.com/sauk.jpeg" />
<meta property="og:image:secure_url" content="https://workingonthemove-images.s3.us-east-2.amazonaws.com/sauk.jpeg" />
<meta name="twitter:text:title" content="Working on the Move" />
<meta name="twitter:image" content="https://workingonthemove-images.s3.us-east-2.amazonaws.com/sauk.jpeg" />

But this article is about taking it deeper. How can we have different metadata for different routes?

You can add metadata to your route definitions in app-routing.module.ts. In my case I have a main blog page with a list of blog articles and child routes for each blog. For any page you want to add data, you can define it with whatever naming convetions you want to use. In my case I chose to create an object called data with an object called seo which contains an object called metaTags which has an array of Open Graph Protocol objects. You can also override the main title and description of your page, but I was only interested in the og and twitter tags.

{ path: 'blog', component: BlogComponent,
  children: [
    { path: 'b20211121', component: B20211121Component,
      data: {
        seo: {
          metaTags: [
            { property: 'og:title', content: 'Living and Working Remotely in Bend Oregon in the Off-Season' },
            { property: 'og:description', content: 'Hey Digital Nomads, thinking about living and working in Bend Oregon for a while?' },
            { property: 'og:image', content: 'https://workingonthemove-images.s3.us-east-2.amazonaws.com/Deschutes National Forest2.jpeg' },
            { property: 'og:url', content: 'https://workingonthemove.com/blog/b20211121'},
            { name: 'twitter:text:title', content: 'Living and Working Remotely in Bend Oregon in the Off-Season'},
            { name: 'twitter:image', content: 'https://workingonthemove-images.s3.us-east-2.amazonaws.com/Deschutes National Forest2.jpeg'}
          ]
        },
      rootRoute: false
      }
    },
  ],
  data: {
    seo: {
      metaTags: [
        { property: 'og:title', content: 'Working on the Move Blog Page' },
        { property: 'og:description', content: 'Blogs for remote workers seeking adventure by taking their remote job on the road' },
        { property: 'og:image', content: 'https://workingonthemove-images.s3.us-east-2.amazonaws.com/sauk.jpeg' },
        { property: 'og:url', content: 'https://workingonthemove.com/blog'},
        { name: 'twitter:text:title', content: 'Working on the Move Blog Page'},
        { name: 'twitter:image', content: 'https://workingonthemove-images.s3.us-east-2.amazonaws.com/sauk.jpeg'}
      ]
    },
    rootRoute: true
  }
}

To do the updates of the metadata, I wrote an Angular service I could call. The code allows for updating the Title though I don’t call that. updateMetaTags will loop through the metaTag arrays from the routes data. I was having trouble with relative image locations so I converted them to full URLs. This could also have been done in the metadata itself.

The result of running this code will be that the metadata in the rendered HTML for the page will be the metadata you defined in the route instead of what was defined in the index.html file.

import { Injectable } from '@angular/core';

import { Title, Meta, MetaDefinition } from '@angular/platform-browser';

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

  constructor(private title: Title,
              private meta: Meta) { }

  updateTitle(title: string): void{
    this.title.setTitle(title);
  }

  updateMetaTags(metaTags: MetaDefinition[]): void{
    metaTags.forEach(m => {
      if (typeof m.property === 'string') {
        if (m.property.slice(0, 2) === 'og') {
          if (m.property.slice(3, 8) === 'image') {
            const slashIndex = m.content.lastIndexOf('/') + 1;
            const image = m.content.slice(slashIndex,  m.content.length);
            m.content = 'https://workingonthemove-images.s3.us-east-2.amazonaws.com/' + image;
            const secureImageJson = {
              property: 'og:image:secure_url',
              content: 'https://workingonthemove-images.s3.us-east-2.amazonaws.com/' + image
            };
            this.meta.updateTag(secureImageJson);
          }
          this.meta.updateTag(m);
        }
      }

      if (typeof m.name === 'string') {
        if (m.name.slice(0, 7) === 'twitter') {
          if (m.name.slice(8, 13) === 'image') {
            const slashIndex = m.content.lastIndexOf('/') + 1;
            const image = m.content.slice(slashIndex,  m.content.length);
            m.content = 'https://workingonthemove-images.s3.us-east-2.amazonaws.com/' + image;
            this.meta.updateTag(m);
          }
        }
      }
    });
  }
}

Now, we just have to call the new service. In app.component.ts I added this code to the constructor. It filters so not applied unnecessarily and then calls the updateMetaTags method in our service with the right data.

  constructor(private seoService: SeoServiceService,
              private activatedRoute: ActivatedRoute,
              private router: Router) {
                this.router.events
                .pipe (
                  filter(e => e instanceof NavigationEnd),
                  map(e => this.activatedRoute),
                  map((route) => {
                    while (route.firstChild) {
                      route = route.firstChild;
                    }
                    return route;
                  }),
                  filter((route) => route.outlet === 'primary'),
                  mergeMap((route) => route.data)
                )
                .subscribe(
                data => {
                  const seoData = data['seo'];
                  this.seoService.updateMetaTags(seoData['metaTags']);
                });
               }

You can test this out locally to make sure each route has the correct metadata by inspecting the page (right click > inspect in Chrome) and viewing the rendered html.

<head>
  <meta charset="utf-8">
  <title>Working on the Move</title>
  <meta name="description" content="A guide for remote workers seeking adventure">
  <meta property="og:url" content="https://workingonthemove.com/blog/b20211121">
  <meta property="og:site_name" content="Working on the Move">
  <meta property="og:type" content="website">
  <meta property="og:title" content="Living and Working Remotely in Bend Oregon in the Off-Season">
  <meta property="og:description" content="Hey Digital Nomads, thinking about living and working in Bend Oregon for a while?">
  <meta property="og:image" content="https://workingonthemove-images.s3.us-east-2.amazonaws.com/Deschutes National Forest2.jpeg">
  <meta property="og:image:secure_url" content="https://workingonthemove-images.s3.us-east-2.amazonaws.com/Deschutes National Forest2.jpeg">
  <meta name="twitter:text:title" content="Working on the Move">
  <meta name="twitter:image" content="https://workingonthemove-images.s3.us-east-2.amazonaws.com/Deschutes National Forest2.jpeg">

Some possible code changes

After I started using Angular Universal, I discovered a couple of things I had to change in my app.

I was using the window object to detect the size of the user’s screen in one part of the application. The window object will not work with Angular Universal because the code is being served on the server now. Remove any reference to the window object.

I am using Angular’s Flex Layout. If you are using that, add an import for FlexLayoutServerModule to app.server.module.ts.

These are the issues I encountered. Your application may have other issues you need to troubleshoot.

Hosting your Angular Universal app in AWS Elastic Beanstalk

I have read articles and documentation showing how you can run your Angular Universal application serverless in AWS Lambda. However, I was not able to get this to work. If you can, it will likely be cheaper for you to host.

Instead, I hosted in AWS Elastic Beanstalk. It is pretty easy to set up. First, let’s build the project using npm run build:ssr. Now we need to ready the files for upload and add some information Beanstalk will need.

1: Older versions of Elastic Beanstalk Node.js allow you to specify a Node command to run upon startup. But recent versions have removed Node Command and require that you provide a package.json file with this information. This is NOT the package.json file included in your Angular Universal project. It is a new one you need to create.

2: Create a new file called package.json somewhere outside of your Angular project folder and paste this JSON in, replacing ‘YOUR-NAME-HERE’ with whatever name you want and ‘YOUR PROJECT NAME’ with your Angular project folder name. This will tell Beanstalk to run Node withour main.js Angular Universal file located in the server folder in the dist folder.

{
  "name": "YOUR-NAME-HERE",
  "version": "1.0.0",
  "scripts": {
    "start": "node dist/YOUR PROJECT NAME/server/main.js"
  }
}

3: Zip your Angular dist folder

4: Copy your new package.json file into the zipped file at the same level as the dist folder.

5: Now you can upload the zip to AWS Elastic Beanstalk and it should server your Angular Universal application.

If you have never set up Beanstalk before, it is pretty easy to set up. You have to have an account with AWS first. Then go to Elastic Beanstalk and click the Create Application button in the region you want to use.

Give it a name and be sure to select Node.js under Platform.

In the Application Code section, select Upload your code, and upload your zip file.

It will take a few minutes to get set up. Once it is up and running you will see a link near the top that you can use to access your web site.

I already owned a domain for my site where I previously used AWS S3 with Cloudfront to server the site. I just changed my Route 53 record for the site to point to Beanstalk instead and it worked.

Note AWS Elastic Beanstalk will have a cost so please educate yourself on the cost of using it.

Conclusion

We covered a lot in this article. We learned how to convert your Angular SPA application to Angular Universal, add metadata for seo and link preview and host your application in AWS Elastic Beanstalk.

As a side note, I am a Digital Nomad. I have a remote job and have taken it on the road, first in an RV and now from Airbnb to Airbnb. I have been living this lifestyle for nearly three years now. If you want to learn how to do this, check out WorkingOnTheMove.com.

Feel free to contact me at dstaud@yahoo.com if you have any questions or comments!

Keep on developing!

Written on December 23, 2021