Published
Dominik Chrástecký - Blog Making Your Angular App SEO-Friendly with SSR Making Your Angular App SEO-Friendly with SSR- 16 min read
Making Your Angular App SEO-Friendly with SSR

Unless you’ve been living under a rock, you’ve probably noticed that Angular has supported server-side rendering (SSR) for quite a while now. Let’s look at what it can actually do for SEO!
SSR support in Angular makes it suitable for a whole new category of apps: content websites that need SEO and other metadata rendered directly in the HTML source instead of client-side at runtime.
And the best part is that most of the time you don’t need to do anything special!
Changing the title
You’ve most likely done this already, and SSR doesn’t change a thing. You still do it the same way you always did: inject the Title service from Angular and set the title:
import {Title} from "@angular/platform-browser";
// then in ngOnInit or somewhere like that
this.title.setTitle('Cool New Title');
And that’s it — it works automatically both client-side and server-side.
Changing meta tags
Changing meta tags follows the same principle — Angular has a service for exactly that. Unsurprisingly, it’s simply called Meta, and you can import it from @angular/platform-browser:
import {Meta} from "@angular/platform-browser";
Then you can create a meta tag:
this.meta.addTag({
name: 'description',
content: 'This is a description',
});
Or use a property instead of a name:
this.meta.addTag({
property: 'og:title',
content: 'Cool New Title',
});
This approach, however, has a small caveat: all tags are added one by one as you navigate through the pages, which means you end up with multiple duplicate descriptions or og:title tags. That would be fine if all search bots were server-side only, but some (like Googlebot) actually render client-side JavaScript, and they could get confused. Instead, you can first search for the tag and create it only if it doesn’t exist:
let description = this.meta.getTag("name='description'");
if (!description) {
description = this.meta.addTag({
name: 'description',
});
}
description!.content = 'This is a description';
The syntax for getTag() is attributeName='attribute value', so you can search by pretty much anything. While there should only be one description tag, there might be other tags with multiple instances, and then searching by name is not going to help. In that case, when creating the tag, you can assign a unique ID or another attribute that makes it easy to look up later.
Changing head link tags
The most common SEO use case here is setting the canonical URL. Another common one is linking to an alternate language or version.
This time we don’t get any ready-made Angular service, but it’s easy enough to handle by appending elements directly to the DOM. However, we won’t use the window.document browser API (because it doesn’t exist on the server), but an Angular wrapper instead. You must inject it using an injection token called DOCUMENT:
import {DOCUMENT} from '@angular/core';
// if using constructor injection, also import Inject
import {Inject} from '@angular/core';
// if using the inject function, import it
import {inject} from '@angular/core';
// then inject it in some way
private readonly document = inject(DOCUMENT);
// or constructor injection
constructor(
@Inject(DOCUMENT) private readonly document: Document,
) {
}
After getting a reference to the document object, you can use it the same way as you would use the native window.document object:
const head = this.document.getElementsByTagName('head')[0];
let element: HTMLLinkElement | null = this.document.querySelector(`link[rel='canonical']`) || null;
if (element == null) {
element = this.document.createElement('link') as HTMLLinkElement;
element.rel = 'canonical';
head.appendChild(element);
}
element.href = 'https://example.com';
The above code does the following:
- gets a reference to the
<head>tag - checks whether a
<link>element withrel="canonical"exists- if not, it creates one
- assigns https://example.com as the canonical link
That way, when you navigate to a different page, the existing canonical link gets updated instead of creating another one.
Getting the current URL
Manually assigning the URL doesn’t make much sense. Instead, you want to get the current URL and normalize it in some way. Here the client side differs slightly from the server side, and you need a solution specific to the server side. For canonical links you can even skip the client side entirely, but I’ll include it anyway.
To modify server-side behavior, you need to inject a Request object with the help of the REQUEST injection token:
You can of course use the
inject()function if that’s your thing. From now on I’ll be using constructor injection only, but theinject()function can be used interchangeably with it.
import {Inject, Optional, REQUEST} from '@angular/core';
constructor(
@Optional() @Inject(REQUEST) private readonly request: Request | null,
) {
}
The above only works if you use the
AngularNodeAppEnginein your server.ts. If your app is older and was bootstrapped with theCommonEngine, you need to provide the request object manually.
Additionally, you need to detect whether you’re on the server or in the browser, so inject the platform ID:
import {Inject, PLATFORM_ID} from '@angular/core';
constructor(
@Inject(PLATFORM_ID) private platformId: Object,
) {
}
Then replace the example.com assignment from above with the following:
if (isPlatformServer(this.platformId) && this.request) {
const url = new URL(this.request.url);
element.href = url.toString();
}
This first makes sure the code only runs when you’re on the server and the request object is not null (which it shouldn’t be if you’re on the server, but if you need to write generic code that should work across various environments, it’s better to be safe).
Then it creates a new URL object and assigns the canonical URL to its value. Why wrap it in a URL object? Because we’re going to do some normalization!
Before assigning the URL to the canonical link element, you might want to remove some marketing query parameters:
const url = new URL(this.request.url);
const bannedParams = ['utm_medium', 'utm_source', 'utm_content', 'fbclid']; // add more if you want
for (const param of bannedParams) {
if (url.searchParams.has(param)) {
url.searchParams.delete(param);
}
}
element.href = url.toString();
Or you might want to make sure your canonical hostname is used:
if (url.host === 'www.chrastecky.dev') {
url.host = 'chrastecky.dev';
}
Or any other combination of rules to make your URL the canonical version.
CommonEngine
If you’re not yet using the new(ish) AngularNodeAppEngine, you’re probably using the CommonEngine, which doesn’t include the request and response objects. Your server.ts should contain a call to commonEngine.render(), which should already include some providers in the providers array. Simply add new ones there:
commonEngine.render({
// other parameters
providers: [
// other providers
{provide: REQUEST, useValue: createWebRequestFromNodeRequest(req)},
],
})
Getting the URL client-side
If you want to update the canonical link client-side for any reason, you can subscribe to the router events like this:
if (isPlatformBrowser(this.platformId)) {
this.router.events.subscribe(event => {
if (event instanceof NavigationEnd) {
const canonicalUrl = new URL(`https://${window.location.host}/${event.urlAfterRedirects}`);
// todo update the link element
}
});
}
The above subscribes to all router events and, when it’s a NavigationEnd event, it checks what the URL after redirects is. Note that urlAfterRedirects only contains the path + query + fragment, not the whole URL, so you need to get the hostname from somewhere. Since we already checked that we’re in a browser, it’s safe to get it from window.location.
You could even mostly use this on the server side as well, but currently there is a problem with query parameters — the above code simply doesn’t provide them on the server side (I’m not sure whether that’s intended or a bug).
Returning correct status codes
By default, Angular always returns the status code 200, which means “OK”, and that’s not ideal. For example, if I link to a nonsense page (like https://chrastecky.dev/this-page-does-not-exist), it’s not enough that it shows an error — search engines need to actually see the 404 status code so they know not to index it.
Another great use case might be creating an HTTP interceptor for your API that automatically checks when the API returns 502/503 and returns the same status code, so that bots crawling your site know there’s a temporary hiccup and they should try again later.
If you’re using
CommonEngine, skip the following section — the approach is a little bit different (more on that below).
First, inject the ResponseInit object:
import {Inject, RESPONSE_INIT} from '@angular/core';
constructor(
@Inject(RESPONSE_INIT) private readonly responseInit: ResponseInit | null,
) {
}
Then make sure it’s present and you’re on the server before setting the status code:
if (isPlatformServer(this.platformId) && this.responseInit) {
this.responseInit.status = 404;
}
Status codes in CommonEngine
In CommonEngine you need to provide the whole Response object instead of ResponseInit. To do so, add a new line to the server.ts providers:
commonEngine.render({
// other parameters
providers: [
// other providers
{ provide: RESPONSE, useValue: res },
],
})
You will quickly notice that the RESPONSE injection token doesn’t actually exist, so we have to provide our own. You can put this anywhere you like; I usually have a single injection-tokens.ts file:
export const RESPONSE = new InjectionToken<Response | null>('RESPONSE');
And then inject it as usual:
constructor(
@Optional() @Inject(RESPONSE) private readonly response: Response | null,
) {}
Finally, set the correct status code:
if (isPlatformServer(this.platformId) && this.response) {
this.response.status(404)
}
Redirects
Redirects are an important part of SEO because they tell crawlers that a page has moved somewhere else. In principle, they’re the same as the 404 responses above, and you could implement a redirect like that in a component, but you can also do it without involving the Angular runtime at all — by adding a new route handler directly in server.ts. I’ll be using a static config in this example, but you can obtain it any way you like.
The config
It’s pretty simple:
export type RedirectStatus = 301 | 302;
export interface RedirectRule {
from: string;
to: string;
status?: RedirectStatus;
}
export const redirectRules: RedirectRule[] = [
{ from: '/old1', to: '/new1', status: 301 },
{ from: '/old2', to: '/new2', status: 301 },
{ from: '/old3', to: '/new3', status: 301 },
];
You can put this anywhere you like, for example in a new redirects.ts file.
Redirecting
Somewhere close to the top, add a new route handler. This example is the same for both Angular engines because they both use an Express server, and this part kicks in before either of the two engines runs — meaning before any part of Angular is executed:
app.get('*', (req, res, next) => {
const requestPath = req.path;
const rule = redirectRules.find((item) => item.from === requestPath);
if (!rule) {
next();
return;
}
const status = rule.status ?? 302;
res.redirect(status, rule.to);
});
This is a classic middleware pattern where you get the request object, the response object, and a next handler. Step by step, this code:
- registers a
GEThandler for all routes - checks whether any of the redirect rules matches the request path
- if none matches, calls
next()and does nothing else - otherwise, sets a redirect on the response using the status code and URL from the config
Conclusion
There are obviously many more important SEO topics, but the basic principles are mostly covered here. For example, adding structured data is just a variation of adding the canonical URL, except you add a <script> tag instead of a <link> one.
For me personally, the addition of SSR made it possible to use Angular pretty much everywhere, including the very blog site I’m writing this on. How about you — do you see yourself building your next content-heavy website in Angular?