This documentation is also published as Markdown for efficient machine reading: the whole site is indexed at /llms.txt, and every page has a clean Markdown copy at the same URL with .md appended. These are generated from the same source and cost far fewer tokens to read than this rendered HTML.

Skip to main content Skip to navigation
Guides

Add a custom schema.org JSON-LD type

Define a record that subclasses JsonLdEntity, attribute its properties for System.Text.Json, and either let the front matter own it via IHasStructuredData or render it inline from a Razor page.

Pennington's <StructuredData> component takes any JsonLdEntity and emits it as a <script type="application/ld+json"> in the page head. To support a schema.org type the framework doesn't ship — Recipe, Product, ScholarlyArticle, Event, or anything else — write a record in your own assembly.

There are two ways to wire it in: implement IHasStructuredData on your front matter so the template emits it automatically, or build the entity inline from a Razor page. The capability-interface path is the default; the inline path is the fallback when the page doesn't have a front matter (a hand-routed Razor page) or when the entity depends on something other than front-matter values.

Before you begin

  • A working Pennington site with CanonicalBaseUrl set on PenningtonOptions or DocSiteOptions. The shipped templates skip JSON-LD when this is empty so URLs don't end up relative.

1. Define the record

Subclass JsonLdEntity, override Type with the schema.org type literal, and attribute every field with [JsonPropertyName]. Repeat the [JsonPropertyName("@type")] attribute on the override — System.Text.Json doesn't inherit attributes through override.

csharp
public sealed record JsonLdRecipe : JsonLdEntity
{
    /// <inheritdoc />
    [JsonPropertyName("@type")]
    public override string Type => "Recipe";
  
    /// <summary>Recipe name.</summary>
    [JsonPropertyName("name")]
    public required string Name { get; init; }
  
    /// <summary>Canonical URL of the recipe page.</summary>
    [JsonPropertyName("url")]
    public string? Url { get; init; }
  
    /// <summary>Short description of the dish.</summary>
    [JsonPropertyName("description")]
    public string? Description { get; init; }
  
    /// <summary>Servings count, e.g. "4 servings".</summary>
    [JsonPropertyName("recipeYield")]
    public string? RecipeYield { get; init; }
  
    /// <summary>Prep duration as an ISO 8601 duration, e.g. "PT15M".</summary>
    [JsonPropertyName("prepTime")]
    public string? PrepTime { get; init; }
  
    /// <summary>Cook duration as an ISO 8601 duration, e.g. "PT30M".</summary>
    [JsonPropertyName("cookTime")]
    public string? CookTime { get; init; }
  
    /// <summary>One-line ingredient strings, with amount and unit baked in.</summary>
    [JsonPropertyName("recipeIngredient")]
    public required IReadOnlyList<string> Ingredients { get; init; }
  
    /// <summary>Step text, one entry per instruction.</summary>
    [JsonPropertyName("recipeInstructions")]
    public required IReadOnlyList<string> Instructions { get; init; }
}

This example defines JsonLdRecipe, a Recipe entity record. It is not a framework type — you own it in your own assembly — and it is the record the wiring snippets in steps 3a and 3b instantiate.

The base JsonLdEntity already supplies @context (defaulted to https://schema.org). Override the Context initializer if you need a different vocabulary.

Optional fields stay nullable; JsonLdSerializer is configured with JsonIgnoreCondition.WhenWritingNull, so unset fields drop out of the JSON.

2. Apply the date converter when you have dates

For schema.org dates, attribute the property with [JsonConverter(typeof(JsonLdDateConverter))]. The converter emits yyyy-MM-ddTHH:mm:ssZ regardless of DateTimeKind, matching the wire format Google's rich-results validator expects.

csharp
[JsonPropertyName("datePublished")]
[JsonConverter(typeof(JsonLdDateConverter))]
public DateTime? DatePublished { get; init; }

3a. Wire it through the front matter (capability path)

When the entity's data lives in front matter, implement IHasStructuredData on your front-matter record. The DocSite and BlogSite templates check for the capability and emit whatever entities the front matter yields — no Razor code required.

csharp
public record RecipeFrontMatter : IFrontMatter, IHasStructuredData
{
    public string Title { get; init; } = "";
    public string? Description { get; init; }
    public IReadOnlyList<string> Ingredients { get; init; } = [];
    public IReadOnlyList<string> Steps { get; init; } = [];
  
    public IEnumerable<JsonLdEntity> GetStructuredData(StructuredDataContext context)
    {
        yield return new JsonLdRecipe
        {
            Name = Title,
            Description = Description,
            Url = context.CanonicalUrl,
            Ingredients = Ingredients,
            Instructions = Steps,
        };
    }
}

StructuredDataContext.CanonicalUrl is the absolute URL the template has already resolved (canonical base plus the page's path). StructuredDataContext.FallbackAuthorName is honored by BlogSite when the front matter's Author is empty.

A page can yield multiple entities — pair a Recipe with a BreadcrumbList, or emit a HowTo alongside a Recipe for instruction-heavy pages.

3b. Render inline from a Razor page (escape hatch)

When the entity isn't a function of front matter — a hand-routed landing page, a page that pulls from a data file, a page that wraps a third-party feed — pass the entities directly into <StructuredData>:

razor
@using Pennington.StructuredData
@inject PenningtonOptions Options
  
@if (!string.IsNullOrEmpty(Options.CanonicalBaseUrl))
{
    <StructuredData Entities="BuildEntities()" />
}
  
@code {
    private IEnumerable<JsonLdEntity> BuildEntities()
    {
        yield return new JsonLdRecipe
        {
            Name = "Weeknight pasta with garlic and oil",
            Ingredients = ["1 lb spaghetti", "6 cloves garlic, thinly sliced"],
            Instructions = ["Boil the pasta", "Toast the garlic", "Toss and serve"],
        };
    }
}

Verify

  1. Visit the page in dev mode and view source. Look for <script type="application/ld+json"> with your @type.
  2. Copy the rendered HTML into Google's Rich Results test and confirm the type validates.
  3. If a field is missing from the JSON, check that the property is non-null and that it carries a [JsonPropertyName] attribute — properties without one use the C# member name verbatim.