Angular Universal Open Graph: Server-Side OG Tags With SSR
How to add og:title, og:image, and og:description to Angular Universal (SSR) apps using the Meta service — with per-route dynamic metadata and TransferState.
Why Angular Universal is required for OG tags
Standard Angular (SPA) apps don't generate OG tags that social media crawlers can read. Crawlers like the Twitter bot, LinkedIn scraper, and Discord preview service don't execute JavaScript — they only read the raw HTML response.
Angular Universal (server-side rendering) renders the app to HTML on the server, which means social crawlers receive fully-populated <meta> tags. Without SSR (or pre-rendering), your OG tags will always appear empty to social platforms.
Setting up Angular Universal
# Angular 17+ with built-in SSR (recommended) ng new my-app --ssr # Or add SSR to an existing Angular 17+ project ng add @angular/ssr # Angular 16 and earlier (legacy Universal) ng add @nguniversal/express-engine
Angular 17 introduced native SSR without the separate @nguniversal package. If you're on Angular 17+, use the built-in SSR.
Using the Meta and Title services
Angular provides built-in Title and Meta services for managing <head> tags:
// src/app/services/seo.service.ts
import { Injectable } from "@angular/core";
import { Meta, Title } from "@angular/platform-browser";
export interface SeoConfig {
title: string;
description: string;
image?: string;
imageWidth?: number;
imageHeight?: number;
type?: "website" | "article";
url?: string;
}
@Injectable({ providedIn: "root" })
export class SeoService {
constructor(
private meta: Meta,
private title: Title
) {}
setMetadata(config: SeoConfig): void {
const {
title,
description,
image = "https://yoursite.com/og/default.png",
imageWidth = 1200,
imageHeight = 630,
type = "website",
url,
} = config;
// Set page title
this.title.setTitle(title);
// Standard meta tags
this.meta.updateTag({ name: "description", content: description });
// Open Graph tags
this.meta.updateTag({ property: "og:title", content: title });
this.meta.updateTag({ property: "og:description", content: description });
this.meta.updateTag({ property: "og:image", content: image });
this.meta.updateTag({ property: "og:image:width", content: imageWidth.toString() });
this.meta.updateTag({ property: "og:image:height", content: imageHeight.toString() });
this.meta.updateTag({ property: "og:type", content: type });
if (url) {
this.meta.updateTag({ property: "og:url", content: url });
}
// Twitter Card tags
this.meta.updateTag({ name: "twitter:card", content: "summary_large_image" });
this.meta.updateTag({ name: "twitter:title", content: title });
this.meta.updateTag({ name: "twitter:description", content: description });
this.meta.updateTag({ name: "twitter:image", content: image });
}
}Dynamic OG tags in route components
// src/app/blog/blog-post.component.ts
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { SeoService } from "@/services/seo.service";
import { BlogService } from "@/services/blog.service";
@Component({
selector: "app-blog-post",
template: `<article *ngIf="post">...</article>`,
})
export class BlogPostComponent implements OnInit {
post: any;
constructor(
private route: ActivatedRoute,
private blogService: BlogService,
private seo: SeoService
) {}
ngOnInit(): void {
const slug = this.route.snapshot.paramMap.get("slug");
this.blogService.getPost(slug!).subscribe((post) => {
this.post = post;
this.seo.setMetadata({
title: post.seoTitle || post.title,
description: post.seoDescription || post.excerpt,
image: post.ogImage || "https://yoursite.com/og/blog-default.png",
type: "article",
url: `https://yoursite.com/blog/${slug}`,
});
});
}
}Using resolve guards for SSR OG tags
For OG tags to be server-rendered, the metadata must be set before the HTML response is sent. Use route resolver guards to fetch data server-side:
// src/app/blog/blog-post.resolver.ts
import { Injectable } from "@angular/core";
import { Resolve, ActivatedRouteSnapshot } from "@angular/router";
import { Observable } from "rxjs";
import { tap } from "rxjs/operators";
import { BlogService } from "@/services/blog.service";
import { SeoService } from "@/services/seo.service";
@Injectable({ providedIn: "root" })
export class BlogPostResolver implements Resolve<any> {
constructor(
private blogService: BlogService,
private seo: SeoService
) {}
resolve(route: ActivatedRouteSnapshot): Observable<any> {
const slug = route.paramMap.get("slug")!;
return this.blogService.getPost(slug).pipe(
tap((post) => {
// Set metadata during resolve — server renders this
this.seo.setMetadata({
title: post.title,
description: post.excerpt,
image: post.ogImage,
type: "article",
});
})
);
}
}
// In your route definition:
// { path: "blog/:slug", component: BlogPostComponent, resolve: { post: BlogPostResolver } }Default OG tags in index.html
Add default OG tags to src/index.html as fallback for pages where no specific metadata is set:
<!-- src/index.html --> <head> <meta charset="utf-8"> <title>My Angular App</title> <meta name="description" content="Default description for the site"> <!-- Default OG tags — overridden per-route by the SeoService --> <meta property="og:title" content="My Angular App"> <meta property="og:description" content="Default description for the site"> <meta property="og:image" content="https://yoursite.com/og/default.png"> <meta property="og:image:width" content="1200"> <meta property="og:image:height" content="630"> <meta property="og:type" content="website"> <meta name="twitter:card" content="summary_large_image"> </head>
Verifying SSR OG rendering
# Build and serve SSR npm run build npm run serve:ssr # Verify OG tags are in the server-rendered HTML (not added by JavaScript) curl -s http://localhost:4000/blog/my-post | grep -E 'og:|twitter:' # If tags appear in the curl output, SSR is working. # If tags are ABSENT, the metadata is being set client-side only.
Common Angular Universal OG pitfalls
- Client-side only metadata: The most common bug — setting OG tags in
ngOnInitafter data fetch works for users but not crawlers unless a resolver pre-fetches on the server - isPlatformBrowser guard: Don't wrap
Meta.updateTag()calls inisPlatformBrowser— this prevents server-side tag generation - Transfer State leak: When using TransferState for API data, ensure OG-relevant fields are included in the transferred state
- Missing baseHref: OG image URLs must be absolute — watch out for Angular apps deployed at a non-root base href
Verify your Angular SSR OG tags are working
After deploying your Angular Universal app, paste a page URL into OGFixer to confirm the OG tags are being rendered server-side and show correctly on all platforms.
Check your Angular OG tags free →