neovibrant.dev

Dynamically set title and meta tags based on Angular routes

To optimize a website for SEO, each page should have its own title, meta description and probably a canonical url too.

In Angular 11, this can be achieved by implementing a custom directive and then we can have these nice and clear routes:

const routes: Routes = [
  {
    path: '',
    component: HomePageComponent,
    data: {
      seo: {
        title: 'Acme Inc Home',
        description:
          'Browse the Acme Inc lovely home page.',
        canonicalUrl: '/'
      }
    }
  },
  {
    path: 'about',
    component: AboutPageComponent,
    data: {
      seo: {
        title: 'About Acme Inc',
        description:
          'This is where you can learn more about Acme Inc',
        canonicalUrl: '/about'
      }
    }
  },
  {
    path: 'sneaky/page',
    component: SneakyPageComponent,
    data: {
      robots: 'noindex, nofollow'
    }
  }
];

In the routes above, we can go and add or update the SEO info for each route—that is very convenient. But to make it work, we need to implement a few things things first.

Read custom data from an Angular route

In Angular, each route has a data property. We can add anything we want there and use it later.

First, we need the route. Luckily, Angular already provides that, we can simply inject it in the constructor of a Component.

constructor(
    private route: ActivatedRoute
) {}

The data is actually an Observable, so we need to subscribe to it.

ngOnInit(): void {
  this.route.data.subscribe((data) => {
    // do something with the route data here
  });
}

The data is an Observable because it changes every time the active route changes. Let's come back to this later.

Create <title> and <meta> based on route data

Fortunately Angular already provides 2 services to deal with <title> and <meta>, so let's add them to our constructor.

constructor(
  private route: ActivatedRoute,
  private titleService: Title,
  private metaService: Meta
) {}

Setting the title based on our custom data is very simple:

this.titleService.setTitle(data?.seo?.title);

With a meta tag is a tad more complicated. That's because a page can only have 1 title, but it can have multiple meta tags. So if we want to add a description tag, we have to make sure we delete any existing description tag first so we don't end up with duplicates.

this.metaService.removeTag('name=description');

if (data?.seo?.description) {
  this.metaService.addTag({
    name: 'description',
    content: data.seo.description
  });
}

Dynamically run on route change

Now remember the data is an Observable. This means Angular is going to tell us every time it changes, meaning every time the route changes.

This is great, because it means we don't have to write the seo code in every page. In fact, we can create a directive for it and just make sure it's included all the time.

Perhaps we can be smart about it? For example, your app may have a Header component included on each page. Placing this directive in the header means it will automatically run on all pages without having to worry about it.

So now all we have to do is extract our code into a directive.

@Directive({
  selector: '[appSeo]'
})
export class SeoDirective implements OnInit {
  constructor(
    private route: ActivatedRoute,
    private titleService: Title,
    private metaService: Meta
  ) {}

  ngOnInit(): void {
    this.route.data.subscribe((data) => {
      this.titleService.setTitle(data?.seo?.title);

      this.metaService.removeTag('name=description');
      if (data?.seo?.description) {
        this.metaService.addTag({
          name: 'description',
          content: data.seo.description
        });
      }
    });
  }
}

As long as the seo directive is placed in every page, it will run and do its magic. For example, I'm including it in my HeaderComponent that is used in all my pages.

<header appSeo>
 <!-- stuff I have in the header of my app -->
</header>

Because the HeaderComponent I have is used in all my pages, theappSeo directive will always run and, when the user navigates from a route to another, the title and meta tags are updated based on the values specified in the route config.

It will work both when the code is generated on the server (SSR) and when the user navigates on the client side (CSR).

Problem solved!

Specifying the Canonical URL

When optimizing for SEO, it's a good idea to also have a canonical URL.

This can be added in the same way and in the same directive. The only issue with it is that Angular hasn't provided a service for it out of the box.

We'll have to improvise a little here and create the tag ourselves. Here is an idea of how it could be done.

First, let's create a service for it, to keep all the logic separate from the directive.

@Injectable()
export class LinkCanonicalService {
  private renderer: Renderer2;

  constructor(
    private rendererFactory: RendererFactory2,
    @Inject(DOCUMENT) private document
  ) {
    this.renderer = this.rendererFactory.createRenderer(this.document, {
      id: '-1',
      encapsulation: ViewEncapsulation.None,
      styles: [],
      data: {}
    });
  }

  setCanonicalTag(canonicalUrl: string | undefined): void {
    try {
      const head = this.document.head;
      const existingTag = this.existingTag();
      if (existingTag) {
        this.renderer.removeChild(head, existingTag);
      }

      if (canonicalUrl) {
        const link = this.renderer.createElement('link');
        this.renderer.setAttribute(link, 'rel', 'canonical');
        this.renderer.setAttribute(link, 'href', canonicalUrl);
        head.appendChild(link);
      }
    } catch (e) {
      console.error('Error rendering the canonical link tag');
    }
  }

  private existingTag(): any {
    const selector = `link[rel="canonical"]`;
    return this.document.querySelector(selector);
  }
}

Basically, all this service does is create a <link rel="canonical".../> tag making sure to remove an existing one first. It does it in such a convoluted way because we want the code to run both on the server and on the client and the server doesn't have a browser with a document handy.

We can use it in our directive, similar to how we used the Angular provided services.

export class SeoDirective implements OnInit {
  ...
  ngOnInit(): void {
  ...
    data?.seo?.canonicalUrl
      ? this.linkCanonical.setCanonicalTag(
          'https://example.com' + data.seo.canonicalUrl}
        )
      : this.linkCanonical.setCanonicalTag(undefined);
  }
}

In this example, I'm prepending https://example.com to the canonical URL so that I don't have to repeat it in the routes seo config and I can use shorter URIs there, like /about instead of https://example.com/about.

Using noindex, nofollow

Perhaps you may wish to exclude some pages from being indexed by a search engine. One way to achieve this is to have a <meta name="robots" content="noindex,nofollow" /> tag on the page.

This can be done in the exact same way as we did the meta description. You'll remember that in my routes config above, I actually had a route configured just like that.

{
  path: 'sneaky/page',
  component: SneakyPageComponent,
  data: {
    robots: 'noindex, nofollow'
  }
}

Just like we did previously, we can read the robots info from the active route and add this to our SeoDirective

this.metaService.removeTag('name=robots');
if (this.data?.robots) {
  this.metaService.addTag({
    name: 'robots',
    content: this.data.robots
  });
}

The only thing to note here is to test well and make sure everything works properly. You really wouldn't want the robots directive set incorrectly on pages that should be indexed by search engines.