Mert Özen Further With Every Line
SEO & GEO Structured Data and Schema.org — #02

JSON-LD Syntax and Safe Generation: @context, @type, @id, @graph in ASP.NET Core and Angular SSR

Mert Özen Nov 14, 2026 15 min 7 views
JSON-LD Syntax and Safe Generation: @context, @type, @id, @graph in ASP.NET Core and Angular SSR

The rule for generating JSON-LD safely is to write text with JSON's own \u003C escape and put it on the page as a single @graph; UnsafeRelaxedJsonEscaping plus Html.Raw lets a </script> in a headline split the page and open the door to XSS. We look at @context, @type, @id and @graph, typed records in ASP.NET Core 10, the ScriptTagHelper detail that arrived with .NET 9, and duplicate scripts and silent data corruption in Angular 22 SSR, all with real output.

Version note: This post is based on the JSON-LD 1.1 W3C Recommendation (16 July 2020) and schema.org 30.1; the code was built and run with .NET 10 and Angular 22.2 (TypeScript 6.0).

Guess First

An editor enters the title of a new post in the Bookstore CMS like this: Why </script> in a title breaks pages. The page writes the title into JSON-LD with System.Text.Json and outputs it in Razor with Html.Raw. Question: Does this title break the page? If it does, which JavaScriptEncoder choice breaks it and which one does not? And when the same post is rendered with Angular SSR, is the problem the same, or something completely different?

The answer is three different outcomes depending on the encoder and the framework: safe output, output that splits the page in two and opens the door to XSS, and output that silently corrupts the data without a single error. We will see all of them with real output.

Anatomy of a JSON-LD Block

JSON-LD is the W3C standard that turns an ordinary JSON document into a graph by adding a few keywords. The current version, JSON-LD 1.1, is a W3C Recommendation dated 16 July 2020. Let's look at the snippet from the first post of the series:

{
  "@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 says what the short names stand for. When you write "@context": "https://schema.org", headline really means https://schema.org/headline; JSON-LD processors perform that expansion as the spec describes. @type is the node's type; it can also be an array. Google's Software App documentation finds VideoGame on its own insufficient and gives co-typing such as ["VideoGame", "MobileApplication"] as an example.

Everything else is a property. Every property has an expected type on schema.org: Person or Organization for author, Date or DateTime for datePublished. Schema.org is lenient and also accepts plain text where an object is expected. But Google's Article documentation is explicit about authors: author should be a Person or an Organization, name should carry only the name, and things like a job title or the publisher's name belong in their own properties (jobTitle, publisher).

The author in the example is a node embedded (nested) inside the BlogPosting. The alternative is @graph: several top-level nodes that share one @context, with links between them made through @id.

{
  "@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's general guidelines accept both and tie the choice to the page's structure: nesting when there is one main item with items that belong to it; individual items when they are separate blocks on the page; and @id links for items that are related but not nested. In a blog post the author, the publisher and the breadcrumb are separate things, so Bookstore uses @graph.

The Type Hierarchy and the "Most Specific Type" Rule

Schema.org types form an inheritance tree, and every subtype inherits its parent's properties. Schema.org's BlogPosting page shows the chain like this: Thing > CreativeWork > Article > SocialMediaPosting > BlogPosting. So a BlogPosting is also an Article and a CreativeWork; properties such as headline, author and datePublished come from the parent types.

The "specificity" item in Google's general guidelines asks you to use the most specific applicable type and property name. The Article documentation gives the same recommended property list for Article, NewsArticle and BlogPosting, so marking a blog post as Article is not an error as far as Google is concerned. But saying that a blog post is a blog post is free and accurate; Bookstore uses BlogPosting.

@id: Reusing the Same Entity

@id is a node's identity: an IRI, which JSON-LD 1.1 calls a node identifier. If two nodes carry the same @id, they talk about the same thing, and processors merge their properties. The identifier does not need to be an address you can open in a browser, but adding a fragment to the site's own URL is a practical habit: https://bookstore.example/authors/ada-kaplan#person is both unique and readable. On 9 January 2024 Google also switched the in-page @id references in its documentation examples to # fragments like this.

A good @id gives you three things. It is stable: it does not depend on a row number in the CMS or a value that changes with every deploy. It is language-neutral: Ada Kaplan is the same person on the TR and EN pages, and her @id should stay the same. And the same thing is written the same way everywhere: two IRIs, one with a trailing / and one without, are two different nodes.

There is one limit. Google's documentation describes @id links for items on the same page; it does not say whether it merges the same @id across different pages. So Bookstore also fully describes, on each page, the author and publisher nodes that page refers to. We will see how this looks in the Schema Markup Validator in the third post of the series.

Typed Generation in ASP.NET Core

In Bookstore's ASP.NET Core 10 application every schema.org type is a C# record. @type and @id live in a shared abstract record, and the property names are written as they appear on schema.org with [JsonPropertyName]:

using System.Text.Json.Serialization;

namespace Bookstore.Web.StructuredData;

// Every node carries @type and @id first; the remaining properties use schema.org names
public abstract record SchemaNode(
    [property: JsonPropertyName("@type"), JsonPropertyOrder(-2)] string Type,
    [property: JsonPropertyName("@id"), JsonPropertyOrder(-1)] string Id);

// A reference to a node that is fully described elsewhere in the graph
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);

// Root document: one @context, all nodes in @graph
public sealed record JsonLdDocument([property: JsonPropertyName("@graph")] IReadOnlyList<object> Graph)
{
    [JsonPropertyName("@context"), JsonPropertyOrder(-1)]
    public string Context => "https://schema.org";
}

Three details matter. @type and @id cannot be C# identifiers, so the attribute is required; writing it for the other names too keeps the output from changing silently when someone later changes the PropertyNamingPolicy. JsonPropertyOrder is only for readability: property order has no meaning in JSON-LD, but having @type on top makes the output easier to check by eye. And @graph is an IReadOnlyList<object>; we will see in the pitfalls below why it is not an IReadOnlyList<SchemaNode>.

Serialization happens in one place, with one options object:

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);
}

The page model builds the page's graph, and the layout writes it into head through a partial:

ViewData["Lang"] = Lang;
ViewData["Title"] = post.Title.Get(Lang);
ViewData["JsonLd"] = new SiteGraph("https://bookstore.example").Post(post, author, Lang);
@model JsonLdDocument
@{
    // "!" opts this element out of ScriptTagHelper, which would encode "+" in the type attribute
    // Html.Raw is safe only because JsonLd.Serialize escapes < > & as < > &
}
<!script type="application/ld+json">@Html.Raw(JsonLd.Serialize(Model))</!script>

The first node of the block produced for the English post:

$ 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",

The dates are DateTimeOffset, so they come out with the +03:00 offset; if dateModified were null, WhenWritingNull would leave it out entirely.

Html.Raw and </script>: The Encoder Choice Is a Security Decision

The Html.Raw in the partial looks dangerous at first sight, and on its own it really is. Razor's automatic HTML encoding (we covered it in the Razor syntax post of the ASP.NET Core MVC and Razor Pages series) would turn the JSON into entities such as &quot;, so it cannot be used inside a JSON-LD block; Html.Raw switches encoding off and leaves all of the safety to the serializer. An HTML parser reads the content of a script element up to the first </script sequence, whatever the type is. JSON-LD is a data block, so the browser does not execute it, but if it contains </script> the element ends there and the rest is processed as HTML.

Let's try three encoders with the scenario from the title:

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 is safe but turns every non-ASCII character into a \u sequence; a Turkish headline becomes something unreadable like \u00C7a\u011Fr\u0131. That is valid JSON and Google reads it correctly; it is only bad for a human reading the source. Create(UnicodeRanges.All) lets letters through but still escapes <, >, & and quotes; this is Bookstore's choice. UnsafeRelaxedJsonEscaping does what its name says: </script> comes out as is. When this output is written with Html.Raw, the JSON-LD block closes in the middle of the headline, and the <script>alert(1)</script> that follows runs as a real script. If the headline comes from an editor or a user, that is stored XSS. The broader context is in the A05 Injection post on XSS and Razor encoding in the Web Security series.

Section 7.2 of the JSON-LD 1.1 spec (non-normative) describes the same risk and recommends avoiding sequences inside the script that could be confused with comment or script open/close markers, escaping them with HTML entities if needed; in the same place it notes that those entities remain escaped after JSON-LD processing. So if you write &lt;, the data stays &lt;. JSON's own \u003C escape does not have that problem: the JSON parser turns it back into the < character. We will see what this difference actually breaks on the Angular side shortly.

The second line in the partial is there for a detail that arrived with ASP.NET Core 9. In the dotnet/aspnetcore source, ScriptTagHelper targets every script element with a type attribute starting from the 9.0 branch (for import map support) and encodes the attribute when it rewrites it. With @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers in place, a plain <script type="application/ld+json"> produces this on .NET 10:

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

That is valid HTML; browsers and Google decode the attribute value and read it as application/ld+json. But any tool that scans the page with a regex or grep misses the block. The ! at the start of the element name (<!script>) keeps this element out of tag helper processing, and the output goes back to a plain type="application/ld+json".

mertozen.com's JsonLdBuilder Through This Lens

This site's own Helpers/JsonLdBuilder.cs made the same decision. Its serializer settings:

private static readonly JsonSerializerOptions SerializerOptions = new()
{
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    // Turkish letters are written without escaping; <, >, &, ' are still escaped
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
    WriteIndented = false,
};

The effect is visible on the live pages: the phrase title'dan in a post summary appears in the JSON-LD as title\u0027dan, and the HTML in video descriptions appears as \u003Cp\u003E. So even if a text from the CMS contains </script>, the block does not close. _SeoHead.cshtml writes the output with Html.Raw and a CSP nonce. According to the HTML spec, a script with a non-JavaScript type is a data block and is never executed; the nonce adds nothing security-wise here, but it does no harm either.

Two notes. First, the site currently targets net8.0, which is why the type attribute is plain in the live output. Since _ViewImports.cshtml adds all MVC tag helpers, after a move to .NET 9 or later the same line will come out as ld&#x2B;json. That is not a bug, but it may affect the site's own audit scripts. Second, dates are kept as DateTime and written with ToUniversalTime().ToString("O"); whether that gives the right result depends on DateTime.Kind. The site parses CMS dates with DateTimeStyles.RoundtripKind, so the result is correct as long as the string carries a time zone. The pitfalls section below shows what happens when it does not.

Angular 22 SSR: Generating JSON-LD on the Server

On the Angular side we write a service for the same job. It adds the script element to head with DOCUMENT and Renderer2; the same code runs on Angular's DOM emulation on the server and on the real DOM in the browser:

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 {
    // "<" must never reach the HTML as-is, otherwise "</script>" ends the element early
    const json = JSON.stringify(data)
      .replace(/</g, '\\u003c')
      .replace(/>/g, '\\u003e')
      .replace(/&/g, '\\u0026');

    // reuse the script rendered on the server instead of adding a second one
    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();
  }
}

The post component sets the title and the JSON-LD inside an effect that depends on the route parameter. The route is rendered on the server for every request with RenderMode.Server, and in Angular 22 effects also run during server rendering:

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,
    });
  });
}

The server output for the post whose title contains </script>:

<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>

The first version of this service was two lines shorter: it created a new script on every call and wrote the JSON.stringify output into textContent without escaping.

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);

That version caused two separate problems. The first shows up after hydration: while the script rendered by the server is still in head, the effect in the browser adds another one, and the page ends up with two JSON-LD blocks; the Playwright test in the last post of the series fails with exactly "expected 1, received 2". Finding the existing one with querySelector and reusing it fixes that. The second is sneakier: Angular's server-side DOM emulation turns the < character into an HTML entity when it serializes the script content. In the unescaped version, the headline in the server output was this:

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

That is not XSS, but it is data corruption: the JSON is valid and parses, only the headline now contains &lt; and no longer matches the h1 on the page. In the browser the hydration effect rewrites textContent with the correct value, so everything looks fine in DevTools; what is broken is the version that every system reading the initial HTML sees. Escaping the JSON with \u003c gives the same correct result in both environments.

It is also possible to add JSON-LD only in the browser, with client-side rendering. Google's "Generate structured data with JavaScript" documentation says it can process structured data in the rendered DOM. But that depends on the page going through Google's rendering queue; a JavaScript error, a timeout or a blocked resource wipes out the markup. The details of that process are in the JavaScript SEO post of the Technical SEO series. With SSR available, there is no good reason to keep the markup out of the server output.

In Production

Generate JSON-LD from a single serializer and test that serializer's options. When someone on the team switches to UnsafeRelaxedJsonEscaping "so Turkish characters don't get escaped", the only thing that will catch it is test data with </script> in the headline. We write that test with xUnit in the last post of the series.

Aim for one JSON-LD block per page. Several blocks are valid, but when the layout adds one, a partial adds one and a component adds one, it gets hard to track which block defines which @id. One @graph means one place to check.

Check SSR output in the server response, not in the browser. The entity problem above did not show in DevTools; with curl it showed immediately.

Common Pitfalls

Declaring a polymorphic list with its base type. System.Text.Json serializes by declared type. The experiment below writes the same PersonNode first in a List<Node>, then in a List<object>:

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

// 1) Declared type decides what gets written
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) Dates: what ends up in datePublished
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> writes only @type without any error; name and url are lost. The fix is either IReadOnlyList<object> or adding [JsonDerivedType] to the base type. Bookstore uses the former.

Dates without a time zone. The second part of the same output: a DateTime whose Kind is Unspecified is written without an offset as 2026-11-10T09:30:00. Google's Article documentation says that without a time zone it will assume Googlebot's time zone. Worse, applying ToUniversalTime() to such a value ties the result to the server's time zone. The line above was taken on a machine set to the Istanbul time zone; when the same program runs on a server set to UTC, for example a Linux container with default settings, the last line becomes:

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

Same code, same data, three hours apart. Use DateTimeOffset, or at least guarantee the Kind.

Relative URLs. "image": "/images/cover.png" is not an IRI in JSON-LD but an ambiguous string. image, url, @id and breadcrumb item values should be absolute.

HTML and entity-laden text. Putting a description from the CMS in as is carries <p> tags or &amp; entities into the markup. Google writes that it ignores HTML tags in some fields, but ignoring is not fixing; convert to plain text and write that.

Two conflicting markups on the same page. If a CMS plugin gives datePublished one value and your hand-written block another, Google decides which one to use. Turn the plugin off or reduce it to a single source.

A Small Experiment

★☆☆ Run Bookstore's JsonLd.Serialize with JavaScriptEncoder.Default and compare the size of the Turkish page's JSON-LD with Create(UnicodeRanges.All).

★★☆ Use IReadOnlyList<SchemaNode> instead of IReadOnlyList<object> and add [JsonDerivedType] to SchemaNode for each subtype. Verify that the output stays the same and no discriminator such as $type is added.

★★★ Write a test for the Angular service showing that during client-side navigation from one post to another the old JSON-LD is updated, and that it is removed when navigating to a page that is not a post.

We can now generate JSON-LD safely. In the next post we move on to which types and properties a tech blog really needs: we build an entity graph with BlogPosting, BreadcrumbList, Person, ProfilePage, Organization and WebSite, and audit mertozen.com's output.