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

BlogPosting, BreadcrumbList and the Author Entity: Building an Entity Graph with Person, ProfilePage, Organization and WebSite

Mert Özen Nov 14, 2026 11 min 4 views
BlogPosting, BreadcrumbList and the Author Entity: Building an Entity Graph with Person, ProfilePage, Organization and WebSite

A tech blog's structured data is a BlogPosting linked to a Person, Organization and WebSite that each have a single @id on the site, plus a BreadcrumbList with the right URLs for each language. We show Google's Article, Breadcrumb and ProfilePage recommendations, honest use of dateModified and how the graph resolves in the Schema Markup Validator with .NET 10, and audit mertozen.com's live output.

Version note: This post is based on the state of Google's Article, Breadcrumb, Profile page, Organization and Site names documentation in November 2026 and on schema.org 30.1; the code was built and run with .NET 10.

Guess First

In the JSON-LD of any post on mertozen.com, the author and the publisher are the same person: Mert Özen. The home page and the About page each have a Person node too. Question: how does Google know that the four Person nodes on these three pages are the same person? Second question: can dateModified be earlier than datePublished in a post's JSON-LD, and if so, what does that mean? Third: if the breadcrumb of an English post links to Turkish pages, is that a bug?

The answers to all three are in this site's live output. First we build the right graph in Bookstore, then we look at mertozen.com through the same lens.

What Google Recommends for BlogPosting

Google's Article documentation has no required properties. Instead it recommends adding the properties that fit the page and gives the same list for Article, NewsArticle and BlogPosting: author (with its name and url fields), datePublished, dateModified, headline and image. "Not required" does not mean "unimportant": these properties help Google tell the author, the date and the cover image apart from the rest of the page, and they need to be consistent with each other and with the visible page.

The details in the documentation translate directly into a developer's code:

  • author: every author shown on the page as a separate Person or Organization; name carries only the name, a job title goes into jobTitle and the publisher's name into publisher.
  • author.url: a page that identifies the author on its own (an author page, an About page); sameAs is accepted too, and Google uses both to tell authors apart.
  • image: images related to the post that can be crawled and indexed; Google recommends several high-resolution images (at least 50,000 pixels when width is multiplied by height) in 16x9, 4x3 and 1x1 aspect ratios.
  • Dates: in ISO 8601 format and with a time zone; without a time zone Google assumes Googlebot's time zone.

Bookstore's graph builder gathers these in one method:

public JsonLdDocument Post(Post post, Author author, string lang)
{
    string url = PageUrl(lang, $"/blog/{post.Slug}");
    var posting = new BlogPostingNode(
        Id: $"{url}#article",
        Headline: post.Title.Get(lang),
        Description: post.Summary.Get(lang),
        Image: [$"{baseUrl}{post.ImagePath}"],
        DatePublished: post.PublishedAt,
        // only a real edit after publishing counts as a modification
        DateModified: post.UpdatedAt > post.PublishedAt ? post.UpdatedAt : null,
        Author: [new IdRef(PersonId(author.Slug))],
        Publisher: new IdRef(OrganizationId),
        MainEntityOfPage: url,
        InLanguage: lang,
        IsPartOf: new IdRef(WebSiteId));

    var breadcrumb = new BreadcrumbListNode($"{url}#breadcrumb",
    [
        new(1, "Blog", PageUrl(lang, "/blog")),
        new(2, post.Category.Get(lang), PageUrl(lang, $"/blog/category/{post.CategorySlug}")),
        new(3, post.Title.Get(lang), null),
    ]);

    return new([posting, breadcrumb, Person(author, lang), Organization(), WebSite()]);
}

publisher is not on Google's recommended list, but it exists on schema.org, and the Article documentation wants the publisher's name in publisher rather than in author.name. Bookstore is a company, so the publisher is an Organization.

Using dateModified Honestly

dateModified says when the post last changed, and it can be misused in two ways. The first is deliberate: moving the date to today when the content has not changed. One of the self-assessment questions on Google's helpful content page asks exactly this: are you changing the dates of pages to make them look fresh when the content has not substantially changed? The question sits under the heading about avoiding search-engine-first content. The second is accidental: publishing the CMS's "record updated" time, a typo fix or an automated job as "the post changed".

Google's byline dates documentation also asks for the visible date and the structured data date to be consistent, for no future dates, and for the date to be clearly labelled on the page with something like "Published" or "Updated". Bookstore applies this with two rules: if UpdatedAt is not after PublishedAt, dateModified is not written at all (the condition in the code above), and the "Updated" label on the page is shown under the same condition. That way the markup and the visible page come from the same data and the same rule.

A breadcrumb shows the page's place on the site as a path. According to Google's documentation, the itemListElement of a BreadcrumbList must contain at least two ListItems; each ListItem requires position and name, and item is the URL of that step. The last step does not need item; if it is missing, Google uses the page's own URL. The documentation recommends that the breadcrumb show the typical path a user takes to the page rather than copy the URL structure, and it does not require separate steps for the top-level domain or the page itself.

There is one more change: on 22 January 2025 Google added to the documentation that breadcrumbs are shown only in desktop results; it found them less useful on mobile because they get truncated on small screens. So this markup shows nothing in mobile results, but it still matters on desktop and for Google's understanding of where the page sits.

The breadcrumb of the English Bookstore post:

    {
      "@type": "BreadcrumbList",
      "@id": "https://bookstore.example/en/blog/csharp-books-for-beginners#breadcrumb",
      "itemListElement": [
        {
          "@type": "ListItem",
          "position": 1,
          "name": "Blog",
          "item": "https://bookstore.example/en/blog"
        },
        {
          "@type": "ListItem",
          "position": 2,
          "name": "Book Picks",
          "item": "https://bookstore.example/en/blog/category/book-picks"
        },
        {
          "@type": "ListItem",
          "position": 3,
          "name": "5 C# Books for Beginners"
        }
      ]
    },

Every step's URL has the /en prefix; the hierarchy of the English post is made of English pages. On a multilingual site this detail is easy to miss. Building breadcrumb URLs correctly per language is part of the URL strategy we described in the hreflang post of the Technical SEO series: every language version should be consistent within itself, with its own URLs.

The Entity Graph: One Person, One @id

A post's JSON-LD can be correct on its own and still be scattered when you look across the site. If the author is written as a separate anonymous Person on every page, whether Google understands that they are the same person comes down to name similarity and links. @id removes that ambiguity: every lasting entity on the site gets a single identity, and every page refers to it with the same identity.

// Builds stable @id values and the nodes for each page type
public sealed class SiteGraph(string baseUrl)
{
    public string OrganizationId => $"{baseUrl}/#organization";
    public string WebSiteId => $"{baseUrl}/#website";
    public string PersonId(string authorSlug) => $"{baseUrl}/authors/{authorSlug}#person";

    public static string Prefix(string lang) => lang == "en" ? "/en" : "";

    public string PageUrl(string lang, string path) => $"{baseUrl}{Prefix(lang)}{path}";

    public OrganizationNode Organization() =>
        new(OrganizationId, "Bookstore", $"{baseUrl}/", $"{baseUrl}/images/logo-512.png",
            ["https://github.com/bookstore-example"]);

    public WebSiteNode WebSite() =>
        new(WebSiteId, "Bookstore", $"{baseUrl}/", ["tr", "en"], new IdRef(OrganizationId));

    public PersonNode Person(Author author, string lang) =>
        new(PersonId(author.Slug), author.Name, PageUrl(lang, $"/authors/{author.Slug}"),
            author.JobTitle.Get(lang), new IdRef(OrganizationId), author.SameAs);

    public JsonLdDocument Home() => new([Organization(), WebSite()]);

Bookstore has four lasting entities. Organization (the Bookstore company) and WebSite (the site) take their identities from fragments on the root URL. Google's Site names documentation considers WebSite markup on the home page alone, at the root of the domain or subdomain, to be enough; name and url are required, and alternateName is recommended for a short name. The Organization documentation defines no required properties either; it recommends placing it on the home page or on a single page such as "About us" and says logo helps it understand which logo to show.

The Person's identity is tied to the author page and is language-neutral: Ada Kaplan appears with the same @id on the TR and EN pages, and only url points to the author page in that language. The author page itself is a ProfilePage:

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "ProfilePage",
      "@id": "https://bookstore.example/en/authors/ada-kaplan#profilepage",
      "url": "https://bookstore.example/en/authors/ada-kaplan",
      "inLanguage": "en",
      "isPartOf": {
        "@id": "https://bookstore.example/#website"
      },
      "mainEntity": {
        "@id": "https://bookstore.example/authors/ada-kaplan#person"
      }
    },
    {

Google's Profile page documentation defines this type for pages that focus on one person or organization, and it explicitly lists an author page on a news site and a blog's "About me" page among the valid examples. mainEntity and its name (or alternateName) are required; sameAs, image and description are recommended. In Bookstore, mainEntity is the same @id as the post's author: this is where the graph connects.

For a personal site the question is different: is the publisher an Organization or a Person? On schema.org publisher accepts both. On a personal blog with no company behind it, making the publisher the same Person as the author is the honest choice; then author and publisher point to the same @id, and the person is described once, completely.

Markup Does Not Prove Authorship

Adding GitHub, LinkedIn and YouTube profiles through sameAs helps tell this person apart from other people named "Ada Kaplan". But markup is a claim, not evidence. The experience, expertise, authoritativeness and trust signals we discussed in the SEO Fundamentals series post "How Google Ranks: Ranking Systems, Core Updates and E-E-A-T" are described in Google's own documentation in terms of the page's visible content and the site's reputation. Without a visible byline, an author page that actually describes the author, and links from that page to real profiles, the Person in the JSON-LD is an empty box. The markup's job is to make sure the machine does not misread information that is already visible.

What the Graph Looks Like in the Schema Markup Validator

In the first version of the Bookstore post, the BlogPosting referenced the WebSite through isPartOf, but the WebSite node was defined only on the home page. We gave the page's HTML to schema.org's Schema Markup Validator and dumped the result as plain text:

objects=3 errors=0 warnings=0
  BreadcrumbList  https://bookstore.example/blog/csharp-books-for-beginners#breadcrumb
  CreativeWork    https://bookstore.example/#website
  BlogPosting     https://bookstore.example/blog/csharp-books-for-beginners#article  author -> Person, publisher -> Organization

The validator merged author and publisher correctly through @id: the Person and Organization we wrote as separate nodes appear under the BlogPosting. But the WebSite identity, whose definition is not on the page, became a separate item: a bare CreativeWork whose type was derived from the expected type of isPartOf, because its own type is unknown. No errors or warnings, but the graph is incomplete. After adding the WebSite node to the post page's graph as well:

objects=2 errors=0 warnings=0
  BreadcrumbList  https://bookstore.example/blog/csharp-books-for-beginners#breadcrumb
  BlogPosting     https://bookstore.example/blog/csharp-books-for-beginners#article  author -> Person, publisher -> Organization, isPartOf -> WebSite

Two items, zero errors, zero warnings, and every link resolved. This is the practical form of the rule from the second post: define on a page every node that page refers to.

Auditing mertozen.com Through the Same Lens

We applied the same checks to this site's live output. Running the GraphDump tool from the first post against an English post and filtering the relevant lines:

$ dotnet run -- https://www.mertozen.com/en/blog/title-link-snippet-meta-description https://www.mertozen.com | grep -E 'datePublished|dateModified|  a  "Person"|hakkimda|  item  '
_:b0  datePublished  "2026-11-08T11:04:00.0000000Z"
_:b0  dateModified  "2026-11-08T06:55:22.8866667Z"
_:b1  a  "Person"
_:b1  url  "https://www.mertozen.com/hakkimda"
_:b2  a  "Person"
_:b2  url  "https://www.mertozen.com/hakkimda"
_:b6  item  "https://www.mertozen.com"
_:b7  item  "https://www.mertozen.com/blog"
_:b8  item  "https://www.mertozen.com/blog?categ..."
_:b9  item  "https://www.mertozen.com/blog?serie..."
_:b10  item  "https://www.mertozen.com/en/blog/ti..."

The findings, together with the site's Helpers/JsonLdBuilder.cs and controller code:

FindingEvidenceEffect
No node has an @idEvery node is an anonymous node starting with _:b; author and publisher are two separate Person nodesThe same person appears twice in a page and as many anonymous copies across the site
Person.url changes by pageSite root on the home page, /hakkimda on posts and AboutThe person's identity is described inconsistently
dateModified before publicationModified 06:55:22Z, published 11:04:00ZThe controller's "only if the update is after publication" condition is applied to article:modified_time but not to the JSON-LD
The EN post's breadcrumb goes to TR pagesitem values have no /en prefix; the EN author.url is also /hakkimdaThe EN hierarchy is built from TR URLs
SearchAction on the home pageWebSite.potentialActionThe sitelinks search box was retired in November 2024; harmless but useless

There are solid parts too. The Schema Markup Validator found two items in the live post's HTML, with zero errors and zero warnings:

objects=2 errors=0 warnings=0
  BreadcrumbList
  BlogPosting       author -> Person, publisher -> Person, isPartOf -> CreativeWorkSeries, speakable -> SpeakableSpecification

headline, image, the dates, author.url and sameAs are filled in; images are absolute URLs; the breadcrumb has four steps and the last one is the post's own URL. Since Google has no required properties for Article, this post is not missing anything as far as Article markup goes. For Google's own eligibility view, the same URL has to be run through the Rich Results Test and URL Inspection in Search Console; we could not run that test for this post and do not guess its result. The site owner decides which findings to fix. The cheapest and most effective step is to apply the controller's condition to dateModified as well and to define a single Person @id for the site.

In Production

Generate @id values in one place in the code. In Bookstore every identity comes from a property of SiteGraph; a page that writes "/#org" by hand silently splits the graph in two.

Generate the visible date and the markup date from the same rule. The dateModified problem on mertozen.com comes precisely from having two rules in two places: one for the meta tag, one for the JSON-LD.

Audit language versions separately. Everything can be right on the TR page while the EN page still carries TR URLs. That is why the tests in the last post of the series run for both languages.

Common Pitfalls

Writing the author as plain text. "author": "Ada Kaplan" is acceptable on schema.org, but Google's Article documentation wants a Person with a type and a url (or sameAs).

Putting a title or suffix into name. A value like "Dr. Ada Kaplan, Bookstore Editor" mixes the name with a title and an organization. The title goes in jobTitle, the organization in worksFor or publisher.

Copying the URL into the breadcrumb. A path such as /blog/2026/11/csharp-books produces "2026" and "11" steps; the path the reader follows is "Blog, Book Picks, post".

Thinking you must put the whole Organization and WebSite on every page, or never putting them anywhere but the home page. Google wants these two on the home page; if you define them on the post page because you reference them there, the graph is complete, and if you do not, you see a bare node in the validator. Neither is an error, but the first is clearer.

A Small Experiment

★☆☆ In a post on your own blog, count how many times and with which url the author is defined. Compare it with the home page and the author page.

★★☆ Add a second author to Bookstore and give one post two authors. Verify that author is an array with two @id values and that both Person nodes are defined in the graph.

★★★ Add a check to GraphDump that reports several nodes with the same @type and the same name but no @id as "likely duplicates", and run it on a mertozen.com post.

The core graph of a tech blog is now in place. In the next post we look at the types outside this core: VideoObject, SoftwareApplication and speakable, and the story of the rich results Google has retired since 2023, especially FAQ and HowTo.