Why SEO in Angular is different
Angular is an SPA (Single Page Application) framework. By default, the server sends an empty index.html with a JavaScript bundle that builds the page in the browser. This presents two problems for SEO:
- Google renders JavaScript, but not always completely or quickly. Other search engines (Bing, DuckDuckGo) have limited support
- Social media crawlers (Facebook, Twitter, LinkedIn) don't execute JavaScript. Without SSR, your Open Graph tags are invisible
The solution is Server-Side Rendering (SSR): the server generates the complete HTML and sends it to the browser. Angular 21 has built-in SSR and configuring it is easier than ever.
Enabling SSR in Angular 21
If your project doesn't have SSR, add it with:
ng add @angular/ssrThis generates:
server.ts: The Express serversrc/app/app.config.server.ts: Server configuration- Updates in
angular.jsonfor SSR build
Verify it works
ng serve
# Open view-source:http://localhost:4200
# You should see rendered HTML, not an empty <app-root>The SeoService: your central tool
Centralize all SEO logic in a service. The first code block shows the full implementation.
How to use it in components
@Component({
selector: 'app-blog-post',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<!-- ... -->`,
})
export class BlogPostComponent {
private readonly seo = inject(SeoService);
private readonly route = inject(ActivatedRoute);
constructor() {
effect(() => {
const post = this.post();
if (post) {
this.seo.updateSeo({
title: post.title,
description: post.excerpt,
url: `/blog/${post.slug}`,
image: post.coverImage,
type: 'article',
publishedAt: post.publishedAt,
updatedAt: post.updatedAt,
author: post.author,
tags: post.tags,
});
}
});
}
}Essential meta tags
Every page should have at minimum:
| Tag | Purpose |
|---|---|
<title> |
Title in search results |
meta description |
Description below the title |
og:title |
Title on social media |
og:description |
Description on social media |
og:image |
Image when sharing (1200x630px) |
og:url |
Canonical URL |
canonical |
Prevents duplicate content |
Structured Data with JSON-LD
Structured data helps Google understand the type of content on your page and display rich snippets in search results.
The second code block shows a service that injects JSON-LD for articles and courses.
Rich snippets you can get
- Articles: Date, author, image in results
- Courses: Provider, level, price in results
- FAQ: Expandable questions and answers
- Breadcrumb: Hierarchical navigation
- Organization: Logo and company data
Breadcrumbs structured data
addBreadcrumb(items: Array<{ name: string; url: string }>): void {
const schema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: `https://xbemorelearn.web.app${item.url}`,
})),
};
this.injectSchema(schema);
}SEO in routes
You can use Angular Router resolvers to update SEO before the component loads. The third code block shows this pattern.
For static routes
Use the resolver directly with fixed data:
{
path: 'cursos',
resolve: {
seo: () => {
inject(SeoService).updateSeo({
title: 'Cursos de desarrollo web',
description: 'Cursos gratuitos de Angular, TypeScript, CSS y más.',
url: '/cursos',
});
return true;
},
},
}For dynamic routes
SEO is updated in the component when data is available:
{
path: ':slug',
loadComponent: () => import('./course-detail')
.then(m => m.CourseDetailComponent),
// The component updates SEO with course data
}Sitemap and robots.txt
robots.txt
Create src/robots.txt and add it to assets in angular.json:
User-agent: *
Allow: /
Sitemap: https://xbemorelearn.web.app/sitemap.xmlStatic sitemap
For apps with static or pre-rendered content, generate a sitemap at build time:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://xbemorelearn.web.app/</loc>
<lastmod>2026-03-06</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://xbemorelearn.web.app/blog</loc>
<lastmod>2026-03-06</lastmod>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://xbemorelearn.web.app/cursos</loc>
<lastmod>2026-03-06</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
</urlset>Dynamic sitemap
To generate the sitemap automatically with each build, create a prebuild script:
import { writeFileSync } from 'fs';
import { BLOG_POSTS } from '../src/app/data/blog/posts.data';
import { ALL_COURSES } from '../src/app/data/courses';
const baseUrl = 'https://xbemorelearn.web.app';
const staticPages = [
{ url: '/', priority: '1.0', changefreq: 'weekly' },
{ url: '/blog', priority: '0.9', changefreq: 'daily' },
{ url: '/cursos', priority: '0.9', changefreq: 'weekly' },
{ url: '/rutas', priority: '0.8', changefreq: 'monthly' },
];
const blogPages = BLOG_POSTS.map(post => ({
url: `/blog/${post.slug}`,
priority: '0.7',
changefreq: 'monthly' as const,
lastmod: post.updatedAt,
}));
const coursePages = ALL_COURSES.map(course => ({
url: `/cursos/${course.slug}`,
priority: '0.8',
changefreq: 'monthly' as const,
}));
const allPages = [...staticPages, ...blogPages, ...coursePages];
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${allPages.map(page => ` <url>
<loc>${baseUrl}${page.url}</loc>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
</url>`).join('\n')}
</urlset>`;
writeFileSync('src/sitemap.xml', sitemap);Pre-rendering static routes
Angular can pre-render routes at build time, generating static HTML files:
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'blog', renderMode: RenderMode.Prerender },
{ path: 'cursos', renderMode: RenderMode.Prerender },
{ path: 'blog/:slug', renderMode: RenderMode.Prerender },
{ path: 'cursos/:slug', renderMode: RenderMode.Prerender },
{ path: '**', renderMode: RenderMode.Server },
];Pre-rendered routes are pure static HTML: they don't need a server and are served instantly from CDN. It's the best option for content that doesn't change frequently.
Performance as an SEO factor
Google uses Core Web Vitals as a ranking factor:
Largest Contentful Paint (LCP)
The largest visible element should load in less than 2.5 seconds:
- Pre-render critical pages
- Optimize images (WebP/AVIF, lazy loading)
- Minimize blocking CSS and JS
Cumulative Layout Shift (CLS)
Content should not shift after loading:
- Set dimensions on images (
widthandheight) - Use
NgOptimizedImagewhich handles this automatically - Reserve space for asynchronous content
Interaction to Next Paint (INP)
Interactions should respond in less than 200ms:
- Use OnPush change detection
- Avoid heavy computations on the main thread
- Use signals for granular updates
Auditing your SEO
Free tools
- Google Search Console: Real data on how Google sees your site
- Lighthouse: Technical audit integrated in Chrome
- Schema.org Validator: Validate your structured data
- Open Graph Debugger: Facebook Sharing Debugger
- Twitter Card Validator: Preview Twitter Cards
SEO checklist for each page
- Unique and descriptive title (50-60 characters)
- Unique meta description (150-160 characters)
- Clean and descriptive URL
- Complete Open Graph tags
- Canonical URL configured
- JSON-LD structured data
- Images with alt text
- Correct heading hierarchy (a single H1)
- Above-the-fold content visible without JavaScript
- Load time under 3 seconds
Conclusion
SEO in Angular with SSR is not black magic. It's a set of well-defined technical practices: dynamic meta tags, structured data, sitemap, pre-rendering, and performance. The centralized SeoService gives you a single point to manage all of this consistently.
The investment in SEO has compounding returns: every well-optimized page attracts organic traffic that grows over time. For a learning platform like Bemore Learn, this means more students discovering the content without spending on advertising.



Comments (0)
Sign in to comment