Mert Özen Her Satırda Daha İleri
SEO ve GEO Structured Data ve Schema.org — #02

JSON-LD Sözdizimi ve Güvenli Üretim: @context, @type, @id, @graph ve ASP.NET Core / Angular SSR'da Doğru Kodlama

Mert Özen 14 Kas 2026 15 dk 6 görüntülenme
JSON-LD Sözdizimi ve Güvenli Üretim: @context, @type, @id, @graph ve ASP.NET Core / Angular SSR'da Doğru Kodlama

JSON-LD'yi güvenli üretmenin kuralı, metni JSON'un kendi \u003C escape'iyle yazmak ve sayfaya tek bir @graph olarak koymaktır; UnsafeRelaxedJsonEscaping ile Html.Raw bir başlıktaki </script> yüzünden sayfayı bölüp XSS'e kapı açar. @context, @type, @id ve @graph'ı, ASP.NET Core 10'da typed record'larla üretimi, .NET 9 ile gelen ScriptTagHelper ayrıntısını ve Angular 22 SSR'da çift script ile sessiz veri bozulmasını gerçek çıktılarla inceliyoruz.

Sürüm notu: Bu yazı JSON-LD 1.1 spec'ini (16 Temmuz 2020) ve schema.org 30.1'i esas alır; kodlar .NET 10 ve Angular 22.2 (TypeScript 6.0) ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un CMS'inde (Content Management System; içerik yönetim sistemi) bir editör yeni yazının başlığını şöyle giriyor: Why </script> in a title breaks pages. Sayfa, başlığı JSON-LD (JSON for Linking Data; bağlantılı veriyi JSON ile yazma standardı) içine System.Text.Json ile yazıp Razor'da Html.Raw ile basıyor. Soru: Bu başlık sayfayı bozar mı? Bozarsa hangi JavaScriptEncoder seçimi bozar, hangisi bozmaz? Ve aynı yazı Angular SSR (Server-Side Rendering; sayfanın HTML'ini sunucuda üretmek) ile render edildiğinde sorun aynı mı olur, yoksa bambaşka bir şey mi olur?

Cevap, encoder'a ve framework'e göre üç farklı sonuç: güvenli çıktı, sayfayı ikiye bölen ve XSS'e (Cross-Site Scripting; sayfaya saldırganın script'ini sokmak) kapı açan çıktı, ve hiçbir hata vermeden veriyi sessizce bozan çıktı. Hepsini gerçek çıktılarla göreceğiz.

Bir JSON-LD Bloğunun Anatomisi

JSON-LD, sıradan bir JSON dokümanına birkaç anahtar kelime ekleyerek onu bir graph'a çeviren W3C (World Wide Web Consortium; web standartlarını yayımlayan kuruluş) standardı. Güncel sürüm JSON-LD 1.1, 16 Temmuz 2020 tarihli bir W3C Recommendation. Serinin ilk yazısındaki parçaya bakalım:

{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "5 C# Books for Beginners",
  "author": { "@type": "Person", "name": "Ada Kaplan" },
  "datePublished": "2026-11-10T09:30:00+03:00"
}

@context, kısa adların neye karşılık geldiğini söyler. "@context": "https://schema.org" yazdığında headline aslında https://schema.org/headline demektir; JSON-LD processor'ları bu genişletmeyi spec'in tarif ettiği şekilde yapar. @type düğümün tipidir; bir array da olabilir. Google'ın Software App dokümanı, tek başına VideoGame tipini yetersiz bulup ["VideoGame", "MobileApplication"] gibi birlikte tiplemeyi örnek veriyor.

Geri kalan her şey property. Her property'nin schema.org'da bir beklenen tipi (expected type) var: author için Person veya Organization, datePublished için Date veya DateTime. Schema.org esnek; beklenen yerde düz metin de kabul ediyor. Ama Google'ın Article dokümanı yazarlar için açık: author bir Person ya da Organization olmalı, name yalnızca adı taşımalı, ünvan veya yayıncı adı gibi şeyler kendi property'lerine (jobTitle, publisher) gitmeli.

Örnekteki author, BlogPosting'in içine gömülmüş (nested) bir düğüm. Bunun alternatifi @graph: aynı @context'i paylaşan birden çok üst seviye düğüm ve aralarında @id ile kurulan bağlar.

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "BlogPosting",
      "@id": "https://bookstore.example/blog/csharp-books-for-beginners#article",
      "headline": "5 C# Books for Beginners",
      "author": { "@id": "https://bookstore.example/authors/ada-kaplan#person" }
    },
    {
      "@type": "Person",
      "@id": "https://bookstore.example/authors/ada-kaplan#person",
      "name": "Ada Kaplan"
    }
  ]
}

Google'ın genel yönergeleri ikisini de kabul ediyor ve seçimi sayfanın yapısına bağlıyor: ana bir öğe ve ona ait öğeler varsa nesting; sayfada ayrı ayrı duran öğeler varsa tek tek; ilişkili ama iç içe olmayan öğeler varsa @id ile bağlamak. Bir blog yazısında yazar, yayıncı ve breadcrumb ayrı şeyler olduğu için Bookstore @graph kullanıyor.

Tip Hiyerarşisi ve "En Spesifik Tip" Kuralı

Schema.org tipleri bir inheritance ağacı oluşturur ve her alt tip üst tipinin property'lerini devralır. schema.org'un BlogPosting sayfası zinciri şöyle gösteriyor: Thing > CreativeWork > Article > SocialMediaPosting > BlogPosting. Yani bir BlogPosting aynı zamanda bir Article ve bir CreativeWork; headline, author ve datePublished gibi property'ler üst tiplerden geliyor.

Google'ın genel yönergelerindeki "specificity" maddesi uygulanabilir en spesifik tipi ve property adını kullanmanı istiyor. Article dokümanı Article, NewsArticle ve BlogPosting için aynı önerilen property listesini veriyor, yani Google açısından bir blog yazısını Article olarak işaretlemek hata değil. Ama bir blog yazısının blog yazısı olduğunu söylemek bedava ve doğru; Bookstore BlogPosting kullanıyor.

@id: Aynı Entity'yi Tekrar Kullanmak

@id, bir düğümün kimliğidir: JSON-LD 1.1'in node identifier dediği bir IRI (Internationalized Resource Identifier; Unicode karakter de içerebilen kaynak kimliği). İki düğüm aynı @id'yi taşıyorsa aynı şeyden bahsediyordur; processor'lar property'lerini birleştirir. Bu kimliğin tarayıcıda açılabilen bir adres olması gerekmiyor, ama sitenin kendi URL'i üzerine bir fragment eklemek pratik bir alışkanlık: https://bookstore.example/authors/ada-kaplan#person hem benzersiz hem okunur. Google da 9 Ocak 2024'te dokümanlarındaki örneklerde sayfa içi @id referanslarını bu tür # fragment'lerine çevirdi.

İyi bir @id üç şeyi sağlar. Kararlı olur: CMS'teki bir satır numarasına veya her deploy'da değişen bir değere bağlı olmaz. Dilden bağımsız olur: Ada Kaplan TR ve EN sayfalarda aynı kişi, @id'si de aynı kalmalı. Ve aynı şeyi her yerde aynı yazar: bir sayfada sonda /, diğerinde / olmadan yazılmış iki IRI, iki ayrı düğümdür.

Bir sınır var. Google'ın dokümanları @id ile bağlamayı aynı sayfadaki öğeler için anlatıyor; farklı sayfalardaki aynı @id'leri birleştirip birleştirmediğini söylemiyor. Bu yüzden Bookstore, bir sayfada referans verdiği yazar ve yayıncı düğümlerini o sayfada da tam olarak yazıyor. Bunun Schema Markup Validator'da nasıl göründüğünü serinin üçüncü yazısında göreceğiz.

ASP.NET Core'da Typed Üretim

Bookstore'un ASP.NET Core 10 uygulamasında her schema.org tipi bir C# record. @type ve @id ortak bir abstract record'da, property adları [JsonPropertyName] ile schema.org'daki hâliyle yazılıyor:

using System.Text.Json.Serialization;

namespace Bookstore.Web.StructuredData;

// Her node önce @type ve @id taşır; diğer property'ler schema.org adlarını kullanır
public abstract record SchemaNode(
    [property: JsonPropertyName("@type"), JsonPropertyOrder(-2)] string Type,
    [property: JsonPropertyName("@id"), JsonPropertyOrder(-1)] string Id);

// Graph'ın başka bir yerinde tam tanımlanan bir node'a referans
public sealed record IdRef([property: JsonPropertyName("@id")] string Id);

public sealed record OrganizationNode(
    string Id,
    [property: JsonPropertyName("name")] string Name,
    [property: JsonPropertyName("url")] string Url,
    [property: JsonPropertyName("logo")] string? Logo = null,
    [property: JsonPropertyName("sameAs")] IReadOnlyList<string>? SameAs = null)
    : SchemaNode("Organization", Id);

public sealed record WebSiteNode(
    string Id,
    [property: JsonPropertyName("name")] string Name,
    [property: JsonPropertyName("url")] string Url,
    [property: JsonPropertyName("inLanguage")] IReadOnlyList<string> InLanguage,
    [property: JsonPropertyName("publisher")] IdRef Publisher)
    : SchemaNode("WebSite", Id);

public sealed record PersonNode(
    string Id,
    [property: JsonPropertyName("name")] string Name,
    [property: JsonPropertyName("url")] string Url,
    [property: JsonPropertyName("jobTitle")] string? JobTitle = null,
    [property: JsonPropertyName("worksFor")] IdRef? WorksFor = null,
    [property: JsonPropertyName("sameAs")] IReadOnlyList<string>? SameAs = null)
    : SchemaNode("Person", Id);

// Kök doküman: tek @context, bütün node'lar @graph içinde
public sealed record JsonLdDocument([property: JsonPropertyName("@graph")] IReadOnlyList<object> Graph)
{
    [JsonPropertyName("@context"), JsonPropertyOrder(-1)]
    public string Context => "https://schema.org";
}

Üç ayrıntı önemli. @type ve @id C# identifier'ı olamayacağı için attribute şart; diğer adlar için de attribute yazmak, ileride biri PropertyNamingPolicy'yi değiştirdiğinde çıktının sessizce değişmesini önlüyor. JsonPropertyOrder yalnızca okunabilirlik için: JSON-LD'de property sırasının anlamı yok, ama @type'ın en üstte durması çıktıyı gözle kontrol etmeyi kolaylaştırıyor. Ve @graph bir IReadOnlyList<object>; bunun neden IReadOnlyList<SchemaNode> olmadığını aşağıdaki tuzaklarda göreceğiz.

Serileştirme tek bir yerde, tek bir options nesnesiyle yapılıyor:

using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Unicode;

namespace Bookstore.Web.StructuredData;

public static class JsonLd
{
    private static readonly JsonSerializerOptions Options = new()
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        // Turkish letters stay readable; < > & ' " are still escaped as < etc.
        Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
    };

    public static string Serialize(JsonLdDocument document) => JsonSerializer.Serialize(document, Options);
}

Page model sayfanın graph'ını kuruyor, layout da onu bir partial ile head'e basıyor:

ViewData["Lang"] = Lang;
ViewData["Title"] = post.Title.Get(Lang);
ViewData["JsonLd"] = new SiteGraph("https://bookstore.example").Post(post, author, Lang);
@model JsonLdDocument
@{
    // "!" bu element'i ScriptTagHelper'ın dışında tutar; yoksa type attribute'undaki "+" encode edilir
    // Html.Raw is safe only because JsonLd.Serialize escapes < > & as < > &
}
<!script type="application/ld+json">@Html.Raw(JsonLd.Serialize(Model))</!script>

İngilizce yazının ürettiği bloğun ilk düğümü:

$ curl -s http://localhost:5321/en/blog/csharp-books-for-beginners | grep -o '<script type="application/ld+json">.*</script>' | sed 's/<[^>]*script[^>]*>//g' | python3 -m json.tool --indent 2 | head -35
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "BlogPosting",
      "@id": "https://bookstore.example/en/blog/csharp-books-for-beginners#article",
      "headline": "5 C# Books for Beginners",
      "description": "When choosing your first C# book, look at the exercises, not the language: all five books on this list end every chapter with a working project.",
      "image": [
        "https://bookstore.example/images/csharp-books-16x9.png"
      ],
      "datePublished": "2026-11-10T09:30:00+03:00",
      "dateModified": "2026-11-12T14:05:00+03:00",
      "author": [
        {
          "@id": "https://bookstore.example/authors/ada-kaplan#person"
        }
      ],
      "publisher": {
        "@id": "https://bookstore.example/#organization"
      },
      "mainEntityOfPage": "https://bookstore.example/en/blog/csharp-books-for-beginners",
      "inLanguage": "en",
      "isPartOf": {
        "@id": "https://bookstore.example/#website"
      }
    },
    {
      "@type": "BreadcrumbList",
      "@id": "https://bookstore.example/en/blog/csharp-books-for-beginners#breadcrumb",
      "itemListElement": [
        {
          "@type": "ListItem",
          "position": 1,
          "name": "Blog",

Tarihler DateTimeOffset olduğu için +03:00 offset'iyle çıkıyor; dateModified ise null olsaydı WhenWritingNull sayesinde hiç yazılmayacaktı.

Html.Raw ve </script>: Encoder Seçimi Bir Güvenlik Kararıdır

Partial'daki Html.Raw ilk bakışta tehlikeli görünüyor ve gerçekten de tek başına tehlikeli. Razor'un otomatik HTML encoding'i (bunu ASP.NET Core MVC (Model-View-Controller; uygulamayı model, view ve controller olarak ayıran yapı) ve Razor Pages serisinin Razor syntax yazısında konuşmuştuk) JSON'u &quot; gibi entity'lere çevireceği için JSON-LD bloğunda kullanılamıyor; Html.Raw encoding'i kapatıyor ve güvenliğin tamamını serializer'a bırakıyor. HTML parser bir script element'inin içeriğini type ne olursa olsun ilk </script dizisine kadar okur. JSON-LD bir data block olduğu için tarayıcı onu çalıştırmaz, ama içinde </script> geçerse element orada biter ve kalanı HTML olarak işlenir.

Üç encoder'ı başlıktaki senaryoyla deneyelim:

using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Unicode;

var post = new Headline("BlogPosting", "Çağrı'nın notu: </script><script>alert(1)</script> & daha fazlası");

JavaScriptEncoder[] encoders =
[
    JavaScriptEncoder.Default,
    JavaScriptEncoder.Create(UnicodeRanges.All),
    JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
];
string[] names = ["Default", "Create(UnicodeRanges.All)", "UnsafeRelaxedJsonEscaping"];

for (int i = 0; i < encoders.Length; i++)
{
    string json = JsonSerializer.Serialize(post, new JsonSerializerOptions { Encoder = encoders[i] });
    Console.WriteLine($"{names[i]}:");
    Console.WriteLine($"  {json}");
    Console.WriteLine($"  contains \"</script\": {json.Contains("</script", StringComparison.OrdinalIgnoreCase)}");
}

record Headline(
    [property: JsonPropertyName("@type")] string Type,
    [property: JsonPropertyName("headline")] string Text);
Default:
  {"@type":"BlogPosting","headline":"\u00C7a\u011Fr\u0131\u0027n\u0131n notu: \u003C/script\u003E\u003Cscript\u003Ealert(1)\u003C/script\u003E \u0026 daha fazlas\u0131"}
  contains "</script": False
Create(UnicodeRanges.All):
  {"@type":"BlogPosting","headline":"Çağrı\u0027nın notu: \u003C/script\u003E\u003Cscript\u003Ealert(1)\u003C/script\u003E \u0026 daha fazlası"}
  contains "</script": False
UnsafeRelaxedJsonEscaping:
  {"@type":"BlogPosting","headline":"Çağrı'nın notu: </script><script>alert(1)</script> & daha fazlası"}
  contains "</script": True

JavaScriptEncoder.Default güvenli ama ASCII (128 karakterlik temel İngilizce karakter seti) dışındaki her karakteri \u dizisine çeviriyor; Türkçe bir başlık \u00C7a\u011Fr\u0131 gibi okunmaz bir hâl alıyor. Bu geçerli JSON ve Google onu doğru okur; yalnızca kaynağı okuyan insan için kötü. Create(UnicodeRanges.All) harfleri serbest bırakıyor ama <, >, & ve tırnakları hâlâ escape ediyor; Bookstore'un seçimi bu. UnsafeRelaxedJsonEscaping ise adının söylediğini yapıyor: </script> olduğu gibi çıkıyor. Bu çıktı Html.Raw ile basıldığında JSON-LD bloğu başlığın ortasında kapanır, ardından gelen <script>alert(1)</script> gerçek bir script olarak çalışır. Başlık bir editörden ya da kullanıcıdan geliyorsa bu bir stored XSS. Konunun genel çerçevesi Web Güvenliği serisinin XSS ve Razor encoding'i anlatan A05 Injection yazısında.

JSON-LD 1.1 spec'inin 7.2 bölümü (non-normative) aynı riski anlatıyor ve script içinde comment veya script açma/kapama ile karışabilecek dizilerden kaçınmayı, gerekirse bunları HTML entity'leriyle escape etmeyi öneriyor; aynı yerde bu entity'lerin JSON-LD işlendikten sonra da escape edilmiş kalacağını not ediyor. Yani &lt; yazarsan veri &lt; olarak kalır. JSON'un kendi \u003C escape'i bu sorunu yaşamaz: JSON parser onu tekrar < karakterine çevirir. Bu farkın gerçekte ne bozduğunu birazdan Angular tarafında göreceğiz.

Partial'daki ikinci satır ASP.NET Core 9 ile gelen bir ayrıntı için. dotnet/aspnetcore kaynak kodunda ScriptTagHelper, 9.0 dalından itibaren type attribute'u taşıyan her script element'ini hedefliyor (import map desteği için) ve attribute'u yeniden yazarken encode ediyor. @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers varken düz bir <script type="application/ld+json"> yazdığımızda .NET 10 çıktısı şu:

<script type="application/ld&#x2B;json">

Bu geçerli HTML; tarayıcı ve Google attribute değerini decode edip application/ld+json olarak okur. Ama sayfayı regex veya grep ile tarayan her araç bloğu kaçırır. Element adının başındaki ! (<!script>) bu element'i tag helper'ların dışında tutuyor ve çıktı yeniden düz type="application/ld+json" oluyor.

mertozen.com'un JsonLdBuilder'ı Bu Gözle

Bu sitenin kendi Helpers/JsonLdBuilder.cs dosyası aynı kararı vermiş. Serileştirme ayarları:

private static readonly JsonSerializerOptions SerializerOptions = new()
{
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    // Türkçe karakterler escape edilmeden basılır; <, >, &, ' yine escape edilir
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
    WriteIndented = false,
};

Canlı sayfalarda bunun etkisi görülüyor: yazı özetindeki title'dan ifadesi JSON-LD'de title\u0027dan olarak, video açıklamalarındaki HTML ise \u003Cp\u003E olarak çıkıyor. Yani CMS'ten gelen bir metin </script> içerse bile blok kapanmaz. _SeoHead.cshtml çıktıyı Html.Raw ile ve bir CSP (Content Security Policy; tarayıcıya hangi script'lerin çalışabileceğini söyleyen header) nonce'uyla basıyor. HTML spec'ine göre JavaScript dışı bir type taşıyan script bir data block'tur ve hiç çalıştırılmaz; nonce burada güvenlik açısından bir şey eklemiyor, ama zararı da yok.

İki not. Birincisi, site şu an net8.0 hedefliyor; bu yüzden canlı çıktıda type attribute'u düz. _ViewImports.cshtml bütün MVC tag helper'larını eklediği için .NET 9 veya üstüne geçişte aynı satır ld&#x2B;json olarak çıkacak. Bu bir hata değil ama sitenin kendi denetim script'lerini etkileyebilir. İkincisi, tarihler DateTime olarak tutulup ToUniversalTime().ToString("O") ile yazılıyor; bunun doğru sonuç vermesi DateTime.Kind'a bağlı. Site CMS tarihini DateTimeStyles.RoundtripKind ile parse ettiği için string saat dilimi taşıdığı sürece sonuç doğru. Aşağıdaki tuzak bölümü, taşımadığında ne olduğunu gösteriyor.

Angular 22 SSR: JSON-LD'yi Server'da Üretmek

Angular tarafında aynı iş için bir service yazıyoruz. Script element'ini DOCUMENT ve Renderer2 ile head'e ekliyor; server'da Angular'ın DOM (Document Object Model; tarayıcının sayfayı temsil ettiği element ağacı) emülasyonunda, tarayıcıda gerçek DOM'da aynı kod çalışıyor:

import { DOCUMENT, Injectable, RendererFactory2, inject } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class JsonLd {
  private readonly document = inject(DOCUMENT);
  private readonly renderer = inject(RendererFactory2).createRenderer(null, null);

  set(id: string, data: object): void {
    // "<" HTML'e olduğu gibi ulaşmamalı, yoksa "</script>" element'i erken kapatır
    const json = JSON.stringify(data)
      .replace(/</g, '\\u003c')
      .replace(/>/g, '\\u003e')
      .replace(/&/g, '\\u0026');

    // ikinci bir script eklemek yerine server'da render edileni yeniden kullan
    let script = this.document.head.querySelector<HTMLScriptElement>(`script#${id}`);
    if (!script) {
      script = this.renderer.createElement('script') as HTMLScriptElement;
      this.renderer.setAttribute(script, 'type', 'application/ld+json');
      this.renderer.setAttribute(script, 'id', id);
      this.renderer.appendChild(this.document.head, script);
    }
    this.renderer.setProperty(script, 'textContent', json);
  }

  remove(id: string): void {
    this.document.head.querySelector(`script#${id}`)?.remove();
  }
}

Yazı component'i, route parametresine bağlı bir effect içinde title'ı ve JSON-LD'yi set ediyor. Route RenderMode.Server ile her istekte server'da render ediliyor ve Angular 22'de effect'ler server render'ında da çalışıyor:

constructor() {
  effect(() => {
    const p = this.post();
    if (!p) {
      this.jsonLd.remove('page-jsonld');
      return;
    }
    const url = `https://bookstore.example/blog/${p.slug}`;
    this.title.setTitle(`${p.title} | Bookstore`);
    this.jsonLd.set('page-jsonld', {
      '@context': 'https://schema.org',
      '@type': 'BlogPosting',
      '@id': `${url}#article`,
      headline: p.title,
      description: p.summary,
      datePublished: p.publishedAt,
      author: { '@id': `https://bookstore.example/authors/${p.author}#person` },
      mainEntityOfPage: url,
    });
  });
}

Server çıktısı, başlığında </script> geçen yazı için:

<script type="application/ld+json" id="page-jsonld">{"@context":"https://schema.org","@type":"BlogPosting","@id":"https://bookstore.example/blog/html-in-titles#article","headline":"Why \u003c/script\u003e in a title breaks pages","description":"A title that closes the script element early.","datePublished":"2026-11-11T10:00:00+03:00","author":{"@id":"https://bookstore.example/authors/ada-kaplan#person"},"mainEntityOfPage":"https://bookstore.example/blog/html-in-titles"}</script>

Bu service'in ilk hâli iki satır daha kısaydı: her çağrıda yeni bir script oluşturuyor ve JSON.stringify çıktısını escape etmeden textContent'e yazıyordu.

const script = this.renderer.createElement('script') as HTMLScriptElement;
this.renderer.setAttribute(script, 'type', 'application/ld+json');
this.renderer.setAttribute(script, 'id', id);
this.renderer.setProperty(script, 'textContent', JSON.stringify(data));
this.renderer.appendChild(this.document.head, script);

Bu sürümle iki ayrı sorun çıktı. Birincisi hydration'dan sonra: server'ın render ettiği script head'de dururken tarayıcıdaki effect bir tane daha ekliyor ve sayfada iki JSON-LD bloğu oluyor; serinin son yazısındaki Playwright testi tam olarak "beklenen 1, gelen 2" ile düşüyor. querySelector ile var olanı bulup yeniden kullanmak bunu çözüyor. İkincisi daha sinsi: Angular'ın server tarafındaki DOM emülasyonu script içeriğini serileştirirken < karakterini HTML entity'sine çeviriyor. Escape edilmemiş sürümde server çıktısındaki başlık şu oldu:

$ curl -s http://localhost:4321/blog/html-in-titles | grep -o '"headline":"[^"]*"'
"headline":"Why &lt;/script> in a title breaks pages"

Bu bir XSS değil, ama veri bozulması: JSON geçerli, parse ediliyor, yalnızca başlık artık &lt; içeriyor ve sayfadaki h1 ile uyuşmuyor. Tarayıcıda hydration effect'i textContent'i doğru değerle yeniden yazdığı için DevTools'ta her şey düzgün görünüyor; bozuk olan, ilk HTML'i okuyan her sistemin gördüğü sürüm. JSON'u \u003c ile escape etmek iki ortamda da aynı, doğru sonucu veriyor.

JSON-LD'yi yalnızca tarayıcıda, client-side rendering ile eklemek de mümkün. Google'ın "Generate structured data with JavaScript" dokümanı render edilen DOM'daki structured data'yı işleyebildiğini söylüyor. Ama bu, sayfanın Google'ın render kuyruğundan geçmesine bağlı; JavaScript hatası, zaman aşımı ya da engellenen bir kaynak markup'ı yok eder. Bu sürecin ayrıntıları Teknik SEO (arama motoru optimizasyonunun teknik tarafı) serisinin JavaScript SEO yazısında. SSR varken markup'ı server çıktısına koymamak için iyi bir neden yok.

Üretimde Dikkat

JSON-LD'yi tek bir serializer'dan üret ve o serializer'ın options'ını test et. Ekipten biri "Türkçe karakterler kaçmasın" diye UnsafeRelaxedJsonEscaping'e geçtiğinde bunu yakalayacak tek şey, başlığında </script> geçen bir test verisi. Serinin son yazısında bu testi xUnit ile yazıyoruz.

Sayfa başına tek bir JSON-LD bloğu hedefle. Birden çok blok geçerli, ama layout bir blok, partial bir blok, bir component bir blok eklediğinde hangisinin hangi @id'yi tanımladığını takip etmek zorlaşır. Tek bir @graph, kontrol edilecek tek bir yer demek.

SSR çıktısını tarayıcıda değil, sunucu yanıtında kontrol et. Yukarıdaki entity sorunu DevTools'ta görünmüyordu; curl ile hemen görünüyor.

Sık Düşülen Tuzaklar

Polymorphic listeyi base tipiyle declare etmek. System.Text.Json, declared type'a göre serileştirir. Aşağıdaki deneme aynı PersonNode'u önce List<Node>, sonra List<object> içinde yazıyor:

using System.Text.Json;
using System.Text.Json.Serialization;

// 1) Ne yazılacağına declared type karar verir
List<Node> typed = [new PersonNode("Ada Kaplan", "https://bookstore.example/authors/ada-kaplan")];
List<object> untyped = [new PersonNode("Ada Kaplan", "https://bookstore.example/authors/ada-kaplan")];
Console.WriteLine($"List<Node>:   {JsonSerializer.Serialize(typed)}");
Console.WriteLine($"List<object>: {JsonSerializer.Serialize(untyped)}");

// 2) Tarihler: datePublished'a ne düşer
var unspecified = new DateTime(2026, 11, 10, 9, 30, 0);
var withOffset = new DateTimeOffset(2026, 11, 10, 9, 30, 0, TimeSpan.FromHours(3));
Console.WriteLine($"DateTime (Unspecified): {JsonSerializer.Serialize(unspecified)}");
Console.WriteLine($"DateTimeOffset (+03:00): {JsonSerializer.Serialize(withOffset)}");
Console.WriteLine($"Unspecified.ToUniversalTime(): {JsonSerializer.Serialize(unspecified.ToUniversalTime())}");

abstract record Node([property: JsonPropertyName("@type")] string Type);

record PersonNode(
    [property: JsonPropertyName("name")] string Name,
    [property: JsonPropertyName("url")] string Url) : Node("Person");
List<Node>:   [{"@type":"Person"}]
List<object>: [{"name":"Ada Kaplan","url":"https://bookstore.example/authors/ada-kaplan","@type":"Person"}]
DateTime (Unspecified): "2026-11-10T09:30:00"
DateTimeOffset (+03:00): "2026-11-10T09:30:00+03:00"
Unspecified.ToUniversalTime(): "2026-11-10T06:30:00Z"

List<Node> hiçbir hata vermeden yalnızca @type'ı yazıyor; name ve url kayboluyor. Çözüm ya IReadOnlyList<object> ya da base tipe [JsonDerivedType] eklemek. Bookstore ilkini kullanıyor.

Saat dilimsiz tarih. Aynı çıktının ikinci kısmı: Kind'ı Unspecified olan bir DateTime offset'siz 2026-11-10T09:30:00 olarak yazılıyor. Google'ın Article dokümanı saat dilimi verilmezse Googlebot'un saat dilimini varsayacağını söylüyor. Daha kötüsü, böyle bir değere ToUniversalTime() uygulamak sonucu sunucunun saat dilimine bağlıyor. Yukarıdaki satır saat dilimi İstanbul olan bir makinede alındı; aynı program saat dilimi UTC (Coordinated Universal Time; eşgüdümlü evrensel zaman) olan bir sunucuda, örneğin varsayılan ayarlı bir Linux container'ında çalışınca son satır şu oluyor:

Unspecified.ToUniversalTime(): "2026-11-10T09:30:00Z"

Aynı kod, aynı veri, üç saat fark. DateTimeOffset kullan ya da en azından Kind'ı garanti et.

Relative URL. "image": "/images/cover.png" JSON-LD'de bir IRI değil, belirsiz bir string. image, url, @id ve breadcrumb item değerleri mutlak olmalı.

HTML ve entity'li metin. CMS'ten gelen açıklamayı olduğu gibi koymak <p> tag'lerini ya da &amp; entity'lerini markup'a taşır. Google bazı alanlarda HTML tag'lerini yok saydığını yazıyor, ama yok saymak düzeltmek değil; düz metne çevirip öyle yaz.

Aynı sayfada çelişen iki markup. Bir CMS eklentisi datePublished'ı bir değerle, senin elle yazdığın blok başka bir değerle veriyorsa hangisinin kullanılacağına Google karar verir. Eklentiyi kapat ya da tek kaynağa indir.

Küçük Bir Deneme

★☆☆ Bookstore'un JsonLd.Serialize'ını JavaScriptEncoder.Default ile çalıştır ve Türkçe sayfanın JSON-LD'sinin boyutunu Create(UnicodeRanges.All) ile karşılaştır.

★★☆ IReadOnlyList<object> yerine IReadOnlyList<SchemaNode> kullan ve SchemaNode'a her alt tip için [JsonDerivedType] ekle. Çıktının aynı kaldığını ve $type gibi bir discriminator eklenmediğini doğrula.

★★★ Angular service'ine client-side navigation sırasında bir yazıdan diğerine geçince eski JSON-LD'nin güncellendiğini, yazı olmayan bir sayfaya geçince de kaldırıldığını gösteren bir test yaz.

Artık JSON-LD'yi güvenle üretebiliyoruz. Bir sonraki yazıda bir teknik blog için hangi tiplerin ve property'lerin gerçekten gerektiğine geçiyoruz: BlogPosting, BreadcrumbList, Person, ProfilePage, Organization ve WebSite ile bir entity graph kuruyor ve mertozen.com'un çıktısını denetliyoruz.