🔴

GEO for Angular: Complete Implementation Guide

advanced

Published April 18, 2026 · Updated May 13, 2026

Angular 19 introduced Incremental Hydration — @defer blocks hydrate on demand, reducing initial JavaScript and improving LCP. Angular 18+ includes @angular/ssr built-in without Angular Universal. Enable withIncrementalHydration() in provideClientHydration() and withEventReplay() for INP improvements. JSON-LD for GEO is injected server-side via Renderer2.

GEO for Angular: Complete Implementation Guide

Angular requires SSR (Angular Universal) for GEO — client-side rendering is invisible to AI crawlers. Use Angular’s Meta service for meta tags, Title service for titles, and DOCUMENT injection token to create JSON-LD script elements. Configure @angular/ssr for server-side rendering.

Angular’s default client-side rendering (CSR) produces HTML that AI crawlers cannot read — they receive a near-empty shell and no page content. SSR is mandatory for any Angular site that wants to be cited by AI engines.

Installing Angular SSR

# Add SSR to existing project
ng add @angular/ssr

# Or create new project with SSR
ng new my-app --ssr

Component Implementation

// geo-guide.component.ts
import { Component, OnInit, inject, PLATFORM_ID } from '@angular/core'
import { Meta, Title } from '@angular/platform-browser'
import { DOCUMENT, isPlatformBrowser } from '@angular/common'

@Component({
  selector: 'app-geo-guide',
  templateUrl: './geo-guide.component.html',
})
export class GeoGuideComponent implements OnInit {
  private meta = inject(Meta)
  private title = inject(Title)
  private doc = inject(DOCUMENT)
  private platformId = inject(PLATFORM_ID)

  private readonly publishedTime = '2026-04-18T00:00:00Z'
  private readonly url = 'https://yoursite.com/geo-guide'
  private readonly description = 'GEO in Angular with SSR, Meta service, and JSON-LD. Complete guide.'

  ngOnInit() {
    // Set title
    this.title.setTitle('How to Implement GEO in Angular | My Site')

    // Set all meta tags
    const tags = [
      { name: 'description', content: this.description },
      { name: 'author', content: 'My Company' },
      { name: 'robots', content: 'index, follow' },
      // Open Graph
      { property: 'og:type', content: 'article' },
      { property: 'og:title', content: 'How to Implement GEO in Angular' },
      { property: 'og:description', content: this.description },
      { property: 'og:url', content: this.url },
      { property: 'og:site_name', content: 'My Site' },
      { property: 'og:image', content: 'https://yoursite.com/og/geo-guide.jpg' },
      { property: 'og:locale', content: 'en_US' },
      // Article dates (recency signal)
      { property: 'article:published_time', content: this.publishedTime },
      { property: 'article:modified_time', content: this.publishedTime },
      { property: 'article:author', content: 'https://yoursite.com/author/my-company' },
      { property: 'article:section', content: 'Technical Guides' },
      { property: 'article:tag', content: 'GEO' },
    ]

    tags.forEach(tag => this.meta.updateTag(tag))

    // Add canonical link
    const existing = this.doc.querySelector('link[rel="canonical"]')
    if (existing) {
      existing.setAttribute('href', this.url)
    } else {
      const canonical = this.doc.createElement('link')
      canonical.rel = 'canonical'
      canonical.href = this.url
      this.doc.head.appendChild(canonical)
    }

    // Add JSON-LD schema
    this.addJsonLd({
      '@context': 'https://schema.org',
      '@type': 'Article',
      headline: 'How to Implement GEO in Angular',
      description: this.description,
      author: { '@type': 'Organization', name: 'My Company' },
      publisher: {
        '@type': 'Organization',
        name: 'My Site',
        logo: { '@type': 'ImageObject', url: 'https://yoursite.com/logo.png' },
      },
      datePublished: this.publishedTime,
      dateModified: this.publishedTime,
      mainEntityOfPage: { '@type': 'WebPage', '@id': this.url },
    })
  }

  private addJsonLd(schema: object) {
    // Remove existing schema of this type if present
    const existing = this.doc.querySelector('script[type="application/ld+json"]')
    if (existing) existing.remove()

    const script = this.doc.createElement('script')
    script.type = 'application/ld+json'
    script.text = JSON.stringify(schema)
    this.doc.head.appendChild(script)
  }
}

Reusable SEO Service

For larger applications, extract meta tag logic into a shared service:

// seo.service.ts
import { Injectable, inject } from '@angular/core'
import { Meta, Title } from '@angular/platform-browser'
import { DOCUMENT } from '@angular/common'

export interface PageSeoConfig {
  title: string
  description: string
  url: string
  imageUrl?: string
  publishedTime: string
  modifiedTime: string
  authorUrl?: string
  section?: string
  tags?: string[]
  schema?: object
}

@Injectable({ providedIn: 'root' })
export class SeoService {
  private meta = inject(Meta)
  private title = inject(Title)
  private doc = inject(DOCUMENT)

  setSeo(config: PageSeoConfig) {
    this.title.setTitle(`${config.title} | My Site`)

    const metaTags = [
      { name: 'description', content: config.description },
      { name: 'robots', content: 'index, follow' },
      { property: 'og:type', content: 'article' },
      { property: 'og:title', content: config.title },
      { property: 'og:description', content: config.description },
      { property: 'og:url', content: config.url },
      { property: 'og:site_name', content: 'My Site' },
      { property: 'article:published_time', content: config.publishedTime },
      { property: 'article:modified_time', content: config.modifiedTime },
    ]

    if (config.imageUrl) {
      metaTags.push({ property: 'og:image', content: config.imageUrl })
    }

    if (config.authorUrl) {
      metaTags.push({ property: 'article:author', content: config.authorUrl })
    }

    metaTags.forEach(tag => this.meta.updateTag(tag))
    this.setCanonical(config.url)

    if (config.schema) {
      this.addJsonLd(config.schema)
    }
  }

  private setCanonical(url: string) {
    let link = this.doc.querySelector('link[rel="canonical"]') as HTMLLinkElement
    if (!link) {
      link = this.doc.createElement('link')
      link.rel = 'canonical'
      this.doc.head.appendChild(link)
    }
    link.href = url
  }

  private addJsonLd(schema: object) {
    const script = this.doc.createElement('script')
    script.type = 'application/ld+json'
    script.text = JSON.stringify(schema)
    this.doc.head.appendChild(script)
  }
}

robots.txt

Place robots.txt in the src/ directory and configure angular.json to copy it to the dist/ folder:

// angular.json — in architect.build.options.assets
{
  "assets": [
    "src/favicon.ico",
    "src/assets",
    { "glob": "robots.txt", "input": "src/", "output": "/" },
    { "glob": "llms.txt", "input": "src/", "output": "/" },
    { "glob": "sitemap.xml", "input": "src/", "output": "/" }
  ]
}
# src/robots.txt
User-agent: GPTBot
Allow: /

User-agent: OAI-SearchBot
Allow: /

User-agent: ClaudeBot
Allow: /

User-agent: Claude-User
Allow: /

User-agent: Claude-SearchBot
Allow: /

User-agent: PerplexityBot
Allow: /

User-agent: Google-Extended
Allow: /

User-agent: BingBot
Allow: /

Sitemap: https://yoursite.com/sitemap.xml
Sitemap: https://yoursite.com/llms.txt

llms.txt

Create src/llms.txt:

# My Site Name
> Description of what your site does and who it serves.

## Main Content
- [GEO Guide](https://yoursite.com/geo-guide): Complete GEO implementation for Angular
- [Technical Reference](https://yoursite.com/technical): SSR configuration and meta service usage

## About
- [About](https://yoursite.com/about): Team and credentials

Server-Side Route for Dynamic Sitemap

// server.ts (Express server for Angular SSR)
import * as express from 'express'

const app = express()

app.get('/sitemap.xml', async (req, res) => {
  const pages = await getAllPages()

  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://yoursite.com/</loc>
    <lastmod>${new Date().toISOString().split('T')[0]}</lastmod>
  </url>
  ${pages.map(p => `
  <url>
    <loc>https://yoursite.com/${p.slug}</loc>
    <lastmod>${p.updatedAt}</lastmod>
  </url>`).join('')}
</urlset>`

  res.set('Content-Type', 'application/xml')
  res.send(sitemap)
})

GEO Checklist for Angular

  • @angular/ssr installed and configured
  • server.ts with Express SSR server running in production
  • Title service: setTitle() called in ngOnInit
  • Meta service: updateTag() for description, og:*, article:published_time, article:modified_time
  • Canonical link element created via DOCUMENT injection
  • JSON-LD script element created via DOCUMENT injection
  • src/robots.txt with all 8 AI crawlers, copied to dist via angular.json
  • src/llms.txt with site description, copied to dist
  • Sitemap with lastmod dates
  • Inverted pyramid content in component templates
  • Core Web Vitals: LCP < 2.5s, INP < 200ms, CLS < 0.1

Angular 18-19: SSR and Hydration Improvements

@angular/ssr (Angular 18+)

Angular 18+ includes SSR directly in Angular CLI. No separate Angular Universal installation required:

ng add @angular/ssr

This generates server.ts and configures SSR automatically.

Event Replay — INP Improvement (Angular 18)

Angular 18 introduced Event Replay — captures user interactions before hydration completes, eliminating the dead zone where clicks are lost. This directly improves INP (Interaction to Next Paint), a Core Web Vital:

// app.config.ts
import { provideClientHydration, withEventReplay } from '@angular/platform-browser'

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(withEventReplay()),
  ]
}

Incremental Hydration (Angular 19)

Angular 19 introduced Incremental Hydration — the most significant GEO improvement since Angular Universal. @defer blocks hydrate on demand (scroll, interaction, timer):

// app.config.ts
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser'

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(withIncrementalHydration()),
  ]
}
<!-- Component template -->
@defer (hydrate on viewport) {
  <heavy-interactive-component />
}

GEO impact: The server-rendered HTML (what AI crawlers see) contains all the content. JavaScript payload is reduced, improving LCP. AI crawlers receive full content without hydration.

i18n with Hydration (Angular 18)

Angular 18 enabled hydration for internationalized apps — previously impossible. Multi-language sites can now use full SSR with hydration, which means both language versions are fully indexable by AI crawlers.