# The SEO kit — schema.org structured data for a page's head

> `Osysharp.Seo` ships schema.org's shapes as classes: a product with its offer, rating and brand, an article, a breadcrumb trail, the organization behind a site, and the site itself. Each writes its own JSON-LD, with `@context` and `@type` filled in, and each is a `JsonLd` block, so a page's `head { StructuredData = … }` takes any of them, or several in a list.

<!-- id: ui-seo-kit · area: ui · stability: stable · html: https://osysharp.com/reference/ui/seo-kit/ -->

## Summary        {#summary}
Search engines read **structured data** to show more than a link: a product's price and whether it is in stock, where
the page sits in the site, the organization's logo. That data is JSON-LD in schema.org's vocabulary, and its shapes are
easy to get subtly wrong. For example, `offers` needs its own `@type`, availability is a URL, and a breadcrumb's steps
are numbered from 1.

`Osysharp.Seo` writes those shapes for you. You fill in fields and hand the object to the page's
[`head { StructuredData = … }`](https://osysharp.com/reference/ui/head/).

```osy syntax
// app.osy
use Osysharp.Seo@0;
```

## Signature      {#signature}
```osy syntax
JsonLd                  // core — what `StructuredData` holds. JsonLd.Raw(json) · JsonLd.Of([a, b]) · .ToJson()
SeoProduct : JsonLd     // Name · Description · Image · Sku · Gtin · Brand · Url · Offers · Rating
SeoOffer                // Price · Currency · Availability · Condition · Url · PriceValidUntil
SeoRating               // Value · Count · Best
SeoBrand                // Name
SeoArticle : JsonLd     // Type ("Article", "TechArticle", "NewsArticle") · Headline · Description · Language ·
                        //   Published · Modified · Image · Url · Publisher
SeoBreadcrumbList : JsonLd   // Steps — each a SeoBreadcrumb { Name, Url }
SeoOrganization : JsonLd     // Name · Url · Logo · SameAs
SeoWebSite : JsonLd          // Name · Url
enum SeoAvailability    // InStock · OutOfStock · PreOrder · BackOrder · LimitedAvailability · SoldOut · Discontinued
enum SeoCondition       // New · Used · Refurbished · Damaged
```

## Description    {#description}

### A member left null is absent   {#null}
Every block writes itself with `JsonSerializer.Serialize(this)` under `WhenWritingNull`, so a member you do not set
does not appear in the JSON. schema.org reads a missing property as "not stated", which is the truth. The same property
written as `null` would claim something.

### Several blocks are one array   {#several}
A product page usually says two things: the product, and where it sits. `StructuredData = [product, breadcrumbs];`
writes them as one JSON-LD array, which is how JSON-LD carries more than one item. `JsonLd.Of([...])` is the same
thing when you build the list somewhere else.

### What the kit does not model   {#raw}
`JsonLd.Raw(json)` is a block of JSON you wrote yourself, for any schema.org type the kit does not ship: a recipe,
an event, an FAQ. It is checked when the page is served, and text that is not JSON is left out of the head rather than
written into it. You can also derive from `JsonLd` and override `ToJson()`, which is exactly what the kit's classes
do.

### Safe whatever the data says   {#escaping}
A product's name can contain anything, including `</script>`. The head writes the JSON into its script element with
every `<` escaped, so no row value can end that element early. Nothing in the kit, or in a class of your own, needs
to escape anything beyond what JSON needs.

## Examples       {#examples}

### A product page's head   {#example-product}
```osy title="the product, what it costs, and where it sits in the shop" test app=ui-seo-kit
app SeoExample {
  use Osysharp.Seo;
  model "model/**/*.osy";
}

using Osysharp.Seo;

entity Kettle {
  [Required, MaxLength(120)] string Name;
  [Required, MaxLength(120)] string Slug;
  decimal Price;
  int Stock;
  security { allow read when IsAnonymous || IsAuthenticated; }
}

[Route("/k/{slug}")]
[AllowAnonymous]
[Title("Kettle")]
component KettlePage(string slug) {
  var kettle = Kettle.Where(k => k.Slug == slug).FirstOrDefault();
  head {
    Title = kettle == null ? "Not found" : kettle.Name;
    StructuredData = kettle == null ? null : [
      new SeoProduct {
        Name = kettle.Name,
        Url = "https://kettles.example.com/k/" + kettle.Slug,
        Offers = new SeoOffer {
          Price = kettle.Price, Currency = "SEK",
          Availability = kettle.Stock > 0 ? SeoAvailability.InStock : SeoAvailability.OutOfStock,
        },
      },
      new SeoBreadcrumbList { Steps = [
        new SeoBreadcrumb { Name = "Kettles", Url = "https://kettles.example.com/k" },
        new SeoBreadcrumb { Name = kettle.Name },
      ] },
    ];
  }
  render { Text(slug); }
}
```

The product block that page writes, for a kettle in stock:

```json syntax
{"@context":"https://schema.org","@type":"Product","name":"Blue Kettle","url":"https://kettles.example.com/k/blue",
 "offers":{"@type":"Offer","price":349.50,"priceCurrency":"SEK","availability":"https://schema.org/InStock"}}
```

## See also       {#see-also}
- [head](https://osysharp.com/reference/ui/head/) — the `head { }` block `StructuredData` belongs to.
- [[Serve]](https://osysharp.com/reference/api/serve/) — `/sitemap.xml` and `/robots.txt`, the other half of what a crawler reads.
- [JsonSerializer](https://osysharp.com/reference/json/serializer/) — `JsonSerializer.Serialize`, which every block writes itself with.
