Mert Özen Her Satırda Daha İleri
SEO ve GEO Teknik SEO — #09

Angular SSR ile SEO: Server Route'lar, RenderMode, Meta ve Hydration

Mert Özen 8 Kas 2026 11 dk 4 görüntülenme
Angular SSR ile SEO: Server Route'lar, RenderMode, Meta ve Hydration

Angular'da render modu route başına seçilir: blog yazısı Prerender ve getPrerenderParams, arama Server, hesap sayfası Client. Title, Meta ve canonical'ı DOCUMENT ile yöneten service'i, REQUEST ve RESPONSE_INIT'i, HTTP transfer cache'in API çağrısını yarıya indirişini, isPlatformBrowser ve useValue tuzaklarını ve localhost'u bile 400 ile reddeden allowedHosts ayarını gerçek çıktılarla görüyoruz.

Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (@angular/core 22.2.5, @angular/ssr 22.2.6), Node.js 24 ve Express 5 ile derlenip denenmiştir; API'ler (Application Programming Interface) angular.dev'in Kasım 2026 hâli esas alınarak anlatılmıştır.

Önce Tahmin Et

Bookstore ekibi önceki yazıdaki ölçümden sonra Angular uygulamasına SSR (Server-Side Rendering) eklemeye karar verdi: ng add @angular/ssr, build, deploy. Üç şey oluyor.

Birincisi, sunucuyu localhost'ta ilk kez çalıştıran geliştirici her sayfada 400 alıyor. İkincisi, birisi "kaç kez render edildi" bilgisini göstermek için server config'ine useValue ile bir sayaç object'i koymuş; üçüncü istekte sayfada ne yazar? Üçüncüsü, fiyat bilgisi "tarayıcıda daha doğru" diye template'te isPlatformBrowser ile yalnız tarayıcıda gösteriliyor; crawler ilk HTML'de (HyperText Markup Language) fiyatı görür mü? Cevapların üçü de aşağıda, gerçek çıktılarla.

Önceki yazı index'lenecek içeriğin ilk HTML'de olması gerektiğini ölçtü. Bu yazı Angular'da bunun nasıl kurulduğunu route route gösteriyor: render modları, prerender parametreleri, Title/Meta/canonical, status ve header'lar, hydration ve SSR'ın güvenlik ayarı.

ng new --ssr ile Neler Geliyor?

ng new bookstore-ng --ssr (ya da mevcut projede ng add @angular/ssr) dört dosya getiriyor: tarayıcı ve sunucu config'lerini birleştiren app.config.server.ts, route başına render modunu tanımlayan app.routes.server.ts, Express tabanlı Node.js sunucusu server.ts ve sunucu tarafı giriş noktası. Üretilen server config'i şöyle:

import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(withRoutes(serverRoutes))
  ]
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

Şablonun ürettiği app.routes.server.ts tek satırlık: { path: '**', renderMode: RenderMode.Prerender }. Yani her şey build anında prerender edilmeye çalışılıyor. Küçük bir tanıtım sitesi için yeterli, ama arama, kişiye özel sayfalar ve build anında bilinmeyen içerik için yanlış varsayılan. İlk iş bu dosyayı uygulamanın gerçeğine göre yazmak. Tarayıcı tarafında da provideClientHydration() şablonla birlikte geliyor; HTTP (HyperText Transfer Protocol) çağrıları için provideHttpClient(withFetch()) ekledim.

Route Başına Render Modu

Bookstore'un server route'ları, önceki yazıdaki tabloyu koda döküyor:

import { RenderMode, ServerRoute } from '@angular/ssr';
import { BOOKS } from './books';

export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender },
  {
    path: 'books/:slug',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      return BOOKS.map(b => ({ slug: b.slug }));
    },
    // build anında bilinmeyen slug'lar istek başına sunucuda render edilir
  },
  { path: 'old-books/:slug', renderMode: RenderMode.Server, status: 301 },
  { path: 'search', renderMode: RenderMode.Server },
  { path: 'account', renderMode: RenderMode.Client, headers: { 'X-Robots-Tag': 'noindex' } },
  { path: '**', renderMode: RenderMode.Server, status: 404 },
];

Kitap detayı RenderMode.Prerender: herkese aynı ve seyrek değişiyor. getPrerenderParams, parametreli bir route için build'in hangi değerlerle sayfa üreteceğini döndürüyor; gerçek bir uygulamada bu liste bir API'den veya veritabanından gelir. Angular dokümanının bir uyarısı var: bu fonksiyonda inject çağrıları senkron olmalı, yani bir await'ten sonra inject çağrılamaz. Listede olmayan bir slug geldiğinde ne olacağını fallback belirliyor: varsayılan PrerenderFallback.Server (istek anında sunucuda render), diğer seçenekler Client ve None. Bookstore'da varsayılanı kullanıyoruz; serinin altıncı yazısında bilinmeyen slug'ın bu yoldan gerçek 404 aldığını görmüştük.

Build çıktısı neyin prerender edildiğini söylüyor:

$ npm run build
> ng build && node scripts/sitemap.mjs
Prerendered 4 static routes.
sitemap.xml: 4 URLs, 357 bytes

$ cat dist/bookstore-ng/prerendered-routes.json
{
  "routes": {
    "/": {},
    "/books/dune": {},
    "/books/foundation": {},
    "/books/neuromancer": {}
  }
}

Arama RenderMode.Server: her istekte farklı sonuç var ve sonuç sayfası ilk HTML'de gelmeli. Hesap sayfası RenderMode.Client: kişiye özel, index'lenmeyecek, sunucuda render etmenin anlamı yok; header ile noindex alıyor. Prerender'ın sınırını da bil: build anında üretilen HTML, bir sonraki build'e kadar değişmez. Fiyat veya stok gibi sık değişen bilgiyi prerender edilmiş sayfaya gömmek, eski veriyi crawler'a ve kullanıcıya göstermek demek.

Title, Meta ve Canonical

Sayfa başına SEO (Search Engine Optimization) tag'lerini tek bir service yönetiyor. Title ve Meta Angular'ın kendi service'leri; canonical için ise DOCUMENT token'ı kullanılıyor, çünkü sunucuda global bir document yok. Aynı kod sunucuda render sırasında ve tarayıcıda navigasyonda çalışıyor:

import { DOCUMENT, Injectable, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';

const SITE = 'https://bookstore.example';

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

  update(page: { title: string; description: string; path: string; noindex?: boolean }): void {
    this.title.setTitle(`${page.title} | Bookstore`);
    this.meta.updateTag({ name: 'description', content: page.description });
    if (page.noindex) {
      // noindex'li bir sayfa canonical göstermemeli
      this.meta.updateTag({ name: 'robots', content: 'noindex' });
      this.document.head.querySelector('link[rel="canonical"]')?.remove();
    } else {
      this.meta.removeTag('name="robots"');
      this.setCanonical(SITE + page.path);
    }
  }

  private setCanonical(url: string): void {
    // tek bir canonical link: varsa güncelle, yoksa oluştur
    let link = this.document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
    if (!link) {
      link = this.document.createElement('link');
      link.setAttribute('rel', 'canonical');
      this.document.head.appendChild(link);
    }
    link.setAttribute('href', url);
  }
}

Canonical'ın host'u sabit bir değerden geliyor; isteğin Host header'ından değil. Bunun nedenini serinin beşinci yazısında ASP.NET Core tarafında görmüştük; Angular'da da geçerli. Prerender edilen kitap sayfasının ilk HTML'inde üç tag de duruyor:

<title>Dune | Bookstore</title>
<meta name="description" content="A desert planet, a noble family and the spice that everyone wants.">
<link rel="canonical" href="https://bookstore.example/books/dune">

Component'lerde service'i constructor'da veya bir effect içinde çağırmak yeterli; route parametresine bağlı sayfalarda withComponentInputBinding() ile parametreyi input olarak almak, aynı component instance'ı farklı slug'larla yeniden kullanıldığında da tag'lerin güncellenmesini sağlıyor.

Status, Header'lar, REQUEST ve RESPONSE_INIT

Statik kararlar (bu route her zaman 404, bu route her zaman noindex header'ı alır) server route'taki status ve headers alanlarıyla veriliyor. Render sırasında verilen dinamik kararlar için iki token var. RESPONSE_INIT, Web API'nin ResponseInit object'i; component render sırasında status ve headers'ı değiştirebiliyor. Serinin altıncı yazısında bilinmeyen kitap için 404'ü böyle vermiştik. REQUEST ise gelen isteğin kendisi: URL (Uniform Resource Locator), header'lar, cookie'ler. İkisi de yalnız sunucuda, bir istek render edilirken dolu; build sırasında, prerender'da ve tarayıcıda null. Bu yüzden her zaman { optional: true } ile inject edilmeli:

// REQUEST sunucuda gelen Web API Request'idir, tarayıcıda null
protected readonly url = inject(REQUEST, { optional: true })?.url ?? 'browser';

Prerender edilen bir route'ta REQUEST'e dayanan bir karar hiç çalışmaz, çünkü build sırasında istek yok. Status'u isteğe göre değişen bir sayfayı prerender etmek bu yüzden mantıksız; bilinmeyen kitap için 404'ün çalışmasının nedeni, o isteğin fallback ile sunucuda render edilmesi.

Hydration ve HTTP Transfer Cache

provideClientHydration(), tarayıcının sunucudan gelen DOM'u (Document Object Model) atıp yeniden çizmek yerine devralmasını sağlıyor. SEO açısından hydration'ın iki önemi var: kullanıcı ve crawler aynı HTML'i görüyor ve ilk render'da layout kayması olmuyor. Hydration'ın bir parçası da HTTP transfer cache: sunucuda render sırasında yapılan HttpClient çağrılarının yanıtları HTML'in içine gömülüyor ve tarayıcı aynı isteği tekrar yapmıyor. Arama sayfasının HTML'inin sonunda:

<script id="ng-state" type="application/json">{"a9acfa0700b782da1756dc530d49b0f7516777f20205f475cc5241e2c5d7ee4b":{"b":[{"slug":"dune","title":"Dune","author":"Frank Herbert","description":"A desert planet, a noble family and the spice that everyone wants."}],"h":{},"s":200,"st":"OK","u":"/api/search?q=dun","rt":"json"},...}</script>

Etkisini ölçmek için Express'teki arama API'sine her isteği user-agent'ıyla loglayan bir satır ekledim ve sayfayı headless Chrome ile açtım:

# varsayılan: provideClientHydration(withEventReplay())
api hit #1 from node

# provideClientHydration(withEventReplay(), withNoHttpTransferCache())
api hit #1 from node
api hit #2 from Mozilla/5.0 (Macinto

Transfer cache açıkken API'ye yalnız sunucu (Node.js'in fetch'i) gitti; tarayıcı yanıtı HTML'deki ng-state'ten aldı. Kapatınca tarayıcı aynı isteği ikinci kez yaptı. Varsayılan olarak yalnız GET ve HEAD istekleri ve authentication (kimlik doğrulama) veya cookie header'ı taşımayanlar cache'leniyor; bu iyi bir varsayılan, çünkü kişiye özel bir yanıtın HTML'e gömülüp bir CDN'de (Content Delivery Network) cache'lenmesi veri sızıntısı demek. Bir de boyut sınırı var: sunucudaki fetch backend'i yanıt başına varsayılan olarak 1 MB kabul ediyor, aşılırsa NG02825 hatası çıkıyor.

İki Sunucu Tuzağı: isPlatformBrowser ve useValue

Açılıştaki üçüncü soru. Fiyatı yalnız tarayıcıda gösteren component:

@Component({
  selector: 'app-mismatch',
  template: `
    <h1>Dune</h1>
    @if (isBrowser) {
      <p class="price">Price: 12.99</p>
    }
  `,
})
export class Mismatch {
  // anti-pattern: sunucu ve tarayıcı farklı DOM üretiyor
  protected readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
}
$ curl -s http://localhost:20110/debug/mismatch | grep -o '<app-mismatch[^>]*>.*</app-mismatch>'
<app-mismatch ngh="0"><h1>Dune</h1><!----></app-mismatch>

# headless Chrome --dump-dom, hydration sonrası
<app-mismatch ng-version="22.2.5"><h1>Dune</h1><p class="price">Price: 12.99</p><!----></app-mismatch>

İlk HTML'de fiyat yok; JavaScript çalışınca ekleniyor. Bu örnekte Angular @if block'unu hydration sırasında oluşturdu ve konsolda bir hata görmedim; ama sonuç yine de istenmeyen: JavaScript çalıştırmayan crawler fiyatı hiç görmüyor, Google'ın render ettiği sayfa ile ilk HTML farklı ve kullanıcı fiyatın sonradan belirmesiyle bir layout kayması yaşıyor. angular.dev template'te isPlatformBrowser ile farklı içerik render etmemeyi açıkça söylüyor; yalnız tarayıcıda çalışması gereken kod (ör. localStorage okumak) için afterNextRender öneriliyor. DOM yapısının gerçekten uyuşmadığı durumlarda Angular hydration hatası (NG0500 ailesi) verir ve o bölümü baştan render eder.

Açılıştaki ikinci soru, sunucunun uzun yaşayan bir process olmasıyla ilgili. İki sayaç tanımladım; biri useValue, diğeri useFactory ile:

const shared = { count: 0 };

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(withRoutes(serverRoutes)),
    // process yeniden başlayana kadar her istekte aynı object
    { provide: SHARED_COUNTER, useValue: shared },
    // her istekte yeni bir object
    { provide: PER_REQUEST_COUNTER, useFactory: () => ({ count: 0 }) },
  ]
};
$ for i in 1 2 3; do curl -s http://localhost:20110/debug/visits | grep -o '<p>[^<]*</p>' | tr '\n' ' '; echo; done
<p>useValue: 1</p> <p>useFactory: 1</p> <p>url: http://localhost:20110/debug/visits</p>
<p>useValue: 2</p> <p>useFactory: 1</p> <p>url: http://localhost:20110/debug/visits</p>
<p>useValue: 3</p> <p>useFactory: 1</p> <p>url: http://localhost:20110/debug/visits</p>

Angular her istek için yeni bir application instance'ı oluşturuyor, ama useValue'ya verilen object tek; bütün istekler onu paylaşıyor. Sayaç zararsız bir örnek; aynı object'te bir kullanıcının dili, sepeti veya token'ı tutulursa bir sonraki kullanıcının sayfasına sızar. SEO açısından da risk var: istek başına değişmesi gereken canonical veya hreflang bilgisi paylaşılan bir object'te tutulursa, bir sayfa başka bir sayfanın canonical'ıyla render edilebilir.

Güvenlik: allowedHosts

Açılıştaki ilk soru. ng new --ssr ile üretilen sunucuyu ilk çalıştırdığımda her istek 400 döndü:

Node Express server listening on http://localhost:20110
ERROR: Bad Request ("http://localhost:20110/").
Header "host" with value "localhost:20110" is not allowed.

For more information, see https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf

Angular SSR, Host header'ına izin verilen host'lar listesine göre bakıyor ve listede olmayan host'lardan gelen isteklere 400 Bad Request dönüyor; Angular 22.2'de bu liste boşken localhost bile kabul edilmedi. Gerekçe SSRF (Server-Side Request Forgery): sunucu render sırasında göreli URL'leri isteğin host'una göre çözüyor, sahte bir Host header'ı sunucunun kendi adına istenmeyen bir adrese istek yapmasına yol açabilir. Liste angular.json'da build seçeneği olarak veriliyor:

"security": {
  "allowedHosts": ["bookstore.example", "localhost"]
}
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:20110/books/dune
200
$ curl -s -o /dev/null -w "%{http_code}\n" -H "Host: bookstore.example" http://localhost:20110/books/dune
200
$ curl -s -o /dev/null -w "%{http_code}\n" -H "Host: evil.example" http://localhost:20110/books/dune
400

Alternatifler AngularNodeAppEngine'e allowedHosts seçeneği vermek veya Node.js sunucusunda virgülle ayrılmış NG_ALLOWED_HOSTS environment variable'ı. Dokümanın uyarısı: * kullanmak güvenlik riski; açık bir liste ver. Bu ayarın, Host header doğrulamasının ve TransferState ile bundle'a sızan secret'ların ayrıntısı Frontend Güvenliği serisinin "Angular SSR Güvenliği: allowedHosts, Host Header Doğrulama, TransferState ve Bundle'a Sızan Secret'lar" yazısında.

Üretimde Dikkat

Her deploy'da prerender listesini kontrol et. prerendered-routes.json'daki route sayısı beklenenden azsa (ör. getPrerenderParams'ın çağırdığı API build sırasında boş döndüyse) sayfalar sessizce sunucu render'ına düşer; bu bir hata değil ama performans ve maliyet profili değişir. Build pipeline'ında route sayısını assert etmek ucuz bir kontrol.

Render modunu production'da doğrula: sayfanın ilk HTML'indeki ng-server-context attribute'u ssg veya ssr diyor, CSR (Client-Side Rendering) route'larında hiç yok. Bir yapılandırma hatası index'lenecek bir route'u CSR'a düşürürse bu attribute'un kaybolması ilk işaret.

SSR'lı sayfalarda cache stratejisini bilinçli kur. Her istek render edildiği için sunucu yükü prerender'dan yüksek; arama gibi sayfalarda kısa bir Cache-Control veya önde bir CDN cache'i işe yarar. Kişiye özel yanıtları ise asla paylaşılan cache'e koyma.

mertozen.com bu yazının kapsamı dışında kalıyor: site Angular değil, ASP.NET Core MVC (Model-View-Controller) ile sunucuda render ediliyor; yani burada anlatılan hybrid rendering'in sağladığı "içerik ilk HTML'de" özelliği sitede Razor ile zaten var.

Sık Düşülen Tuzaklar

Şablonun '**' → Prerender varsayılanını olduğu gibi bırakmak. Arama ve kişiye özel sayfalar build anında anlamsız bir hâlde üretilir.

Template'te isPlatformBrowser ile farklı içerik göstermek. İlk HTML eksik kalır, layout kayar, hydration bozulabilir.

useValue ile istek başına değişmesi gereken state paylaşmak. Sunucu process'i yaşadıkça bütün istekler aynı object'i görür.

REQUEST ve RESPONSE_INIT'i optional olmadan inject etmek veya prerender edilen route'larda bunlara dayanmak. Build sırasında ikisi de null.

allowedHosts'a * yazmak. 400 hatası kaybolur, SSRF koruması da.

Kişiye özel HTTP yanıtlarını transfer cache ile HTML'e gömmek. Varsayılan ayarlar bunu engelliyor; includeRequestsWithAuthHeaders gibi seçenekleri açmadan önce sayfanın hiçbir yerde paylaşılan bir cache'e girmediğinden emin ol.

Küçük Bir Deneme

★☆☆ Kendi Angular SSR projende dist/.../prerendered-routes.json'a bak ve app.routes.server.ts'teki her route'un hangi modla servis edildiğini curl -s ile ng-server-context attribute'una bakarak doğrula.

★★☆ Bookstore'un getPrerenderParams'ını sabit liste yerine bir API'den okuyacak şekilde değiştir; API build sırasında hata dönerse build'in açık bir mesajla durmasını sağla. Sessizce sıfır route prerender etmesi ile build'in kırılmasını karşılaştır.

★★★ Mismatch component'ini düzelt: fiyat sunucuda da render edilsin, yalnız tarayıcıya özgü bir şey (ör. kullanıcının yerel para birimi tercihi localStorage'dan) afterNextRender içinde uygulansın. İlk HTML'de fiyatın olduğunu curl ile, hydration sonrası DOM'un tutarlı olduğunu headless Chrome ile doğrula.

Bookstore şimdiye kadar tek dilliydi. Bir sonraki yazıda Türkçe ve İngilizce sürümler için URL stratejisini, hreflang'ın üç yerini, x-default'u ve hreflang ile canonical'ın birbirini nasıl desteklemesi gerektiğini, mertozen.com'un ?lang= yapısından /en/ alt klasörüne geçişiyle birlikte ele alacağız.