@jdevalk/astro-seo-graph 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3019 -0
- package/README.md +13 -14
- package/package.json +4 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,3019 @@
|
|
|
1
|
+
# AGENTS.md — seo-graph
|
|
2
|
+
|
|
3
|
+
> This file is for AI coding agents (Claude Code, Cursor, Copilot, etc.)
|
|
4
|
+
> building sites that use `@jdevalk/seo-graph-core` and/or
|
|
5
|
+
> `@jdevalk/astro-seo-graph`. It explains what the library does, how the
|
|
6
|
+
> pieces fit together, and which schema.org entities to use for every
|
|
7
|
+
> common site type.
|
|
8
|
+
|
|
9
|
+
## What this library does
|
|
10
|
+
|
|
11
|
+
seo-graph builds valid, linked schema.org JSON-LD `@graph` arrays from typed
|
|
12
|
+
inputs. Instead of hand-writing JSON-LD (error-prone) or copying snippets
|
|
13
|
+
from schema.org docs (inconsistent), you call piece builders that return
|
|
14
|
+
strongly-typed entities, then wrap them in a `@graph` envelope.
|
|
15
|
+
|
|
16
|
+
Two packages:
|
|
17
|
+
|
|
18
|
+
- **`@jdevalk/seo-graph-core`** — Pure TypeScript, no framework dependency.
|
|
19
|
+
Piece builders, ID factory, graph assembler, deduplication. Use this from
|
|
20
|
+
any runtime.
|
|
21
|
+
- **`@jdevalk/astro-seo-graph`** — Astro integration. `<Seo>` component,
|
|
22
|
+
route factories for schema endpoints, schema map for agent discovery,
|
|
23
|
+
Zod content helpers.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Architecture
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
┌───────────────────────────────────────────────────┐
|
|
31
|
+
│ @jdevalk/seo-graph-core │
|
|
32
|
+
│ │
|
|
33
|
+
│ makeIds() IdFactory │
|
|
34
|
+
│ buildArticle() buildPerson() │
|
|
35
|
+
│ buildWebSite() buildWebPage() │
|
|
36
|
+
│ buildOrganization() │
|
|
37
|
+
│ buildBreadcrumbList() │
|
|
38
|
+
│ buildImageObject() buildVideoObject() │
|
|
39
|
+
│ buildSiteNavigationElement() │
|
|
40
|
+
│ buildCustomPiece() │
|
|
41
|
+
│ assembleGraph() │
|
|
42
|
+
│ deduplicateByGraphId() │
|
|
43
|
+
└──────────────┬────────────────────────────────────┘
|
|
44
|
+
│ used by
|
|
45
|
+
┌──────────┴──────────┐
|
|
46
|
+
│ │
|
|
47
|
+
▼ ▼
|
|
48
|
+
┌────────────────┐ ┌───────────────────┐
|
|
49
|
+
│ astro-seo-graph│ │ Any other runtime │
|
|
50
|
+
│ │ │ (EmDash, Next.js, │
|
|
51
|
+
│ <Seo> │ │ SvelteKit, etc.) │
|
|
52
|
+
│ createSchema │ │ │
|
|
53
|
+
│ Endpoint() │ │ Use core directly │
|
|
54
|
+
│ createSchema │ └───────────────────┘
|
|
55
|
+
│ Map() │
|
|
56
|
+
│ aggregate() │
|
|
57
|
+
│ seoSchema() │
|
|
58
|
+
│ imageSchema() │
|
|
59
|
+
└────────────────┘
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Key principle:** Core has no opinions about your content model, routing, or
|
|
63
|
+
page types. It gives you piece builders. _You_ decide which pieces to assemble
|
|
64
|
+
for each page. This file tells you which pieces to pick.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Installation
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
# Astro projects — install both
|
|
72
|
+
npm install @jdevalk/seo-graph-core @jdevalk/astro-seo-graph
|
|
73
|
+
|
|
74
|
+
# Non-Astro projects — core only
|
|
75
|
+
npm install @jdevalk/seo-graph-core
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## The @id system
|
|
81
|
+
|
|
82
|
+
Every entity in a JSON-LD `@graph` can have an `@id`. Other entities reference
|
|
83
|
+
it by `{ "@id": "..." }`. This is how the graph becomes _linked_ rather than
|
|
84
|
+
flat.
|
|
85
|
+
|
|
86
|
+
`makeIds()` creates an `IdFactory` that generates stable, deterministic `@id`
|
|
87
|
+
URIs for all entity types:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { makeIds } from '@jdevalk/seo-graph-core';
|
|
91
|
+
|
|
92
|
+
const ids = makeIds({
|
|
93
|
+
siteUrl: 'https://example.com',
|
|
94
|
+
personUrl: 'https://example.com/about/', // optional, defaults to siteUrl + '/'
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Available IDs
|
|
99
|
+
|
|
100
|
+
| Property/Method | Returns | Use for |
|
|
101
|
+
| ------------------------ | -------------------------------------------------------- | ------------------------------------ |
|
|
102
|
+
| `ids.person` | `https://example.com/about/#/schema.org/Person` | Site-wide Person entity |
|
|
103
|
+
| `ids.personImage` | `https://example.com/about/#/schema.org/Person/image` | Person's profile image |
|
|
104
|
+
| `ids.website` | `https://example.com/#/schema.org/WebSite` | Site-wide WebSite entity |
|
|
105
|
+
| `ids.navigation` | `https://example.com/#/schema.org/SiteNavigationElement` | Main navigation |
|
|
106
|
+
| `ids.organization(slug)` | `https://example.com/#/schema.org/Organization/{slug}` | Named organization |
|
|
107
|
+
| `ids.country(code)` | `https://example.com/#/schema.org/Country/{code}` | Country entity (ISO 3166) |
|
|
108
|
+
| `ids.webPage(url)` | The URL itself | WebPage entity (canonical URL = @id) |
|
|
109
|
+
| `ids.breadcrumb(url)` | `{url}#breadcrumb` | BreadcrumbList for a page |
|
|
110
|
+
| `ids.article(url)` | `{url}#article` | Article entity for a page |
|
|
111
|
+
| `ids.videoObject(url)` | `{url}#video` | VideoObject for a page |
|
|
112
|
+
| `ids.primaryImage(url)` | `{url}#primaryimage` | Primary image for a page |
|
|
113
|
+
|
|
114
|
+
### How entities reference each other
|
|
115
|
+
|
|
116
|
+
Entities form a tree of references:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
WebSite
|
|
120
|
+
├── publisher → Person or Organization
|
|
121
|
+
├── hasPart → SiteNavigationElement
|
|
122
|
+
│
|
|
123
|
+
├── Blog (optional, for sites with a blog)
|
|
124
|
+
│ └── publisher → Person or Organization
|
|
125
|
+
│
|
|
126
|
+
└── WebPage (one per URL)
|
|
127
|
+
├── isPartOf → WebSite
|
|
128
|
+
├── breadcrumb → BreadcrumbList
|
|
129
|
+
├── primaryImage → ImageObject
|
|
130
|
+
│
|
|
131
|
+
├── BlogPosting or Article (if blog post)
|
|
132
|
+
│ ├── isPartOf → WebPage, Blog
|
|
133
|
+
│ ├── author → Person
|
|
134
|
+
│ ├── publisher → Person or Organization
|
|
135
|
+
│ └── image → ImageObject
|
|
136
|
+
│
|
|
137
|
+
└── VideoObject (if video page)
|
|
138
|
+
└── isPartOf → WebPage
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Blog vs. Article hierarchy:** `Blog` is a `CreativeWork` that represents the
|
|
142
|
+
blog as a publication. `BlogPosting` is a subtype of `Article`. A `BlogPosting`
|
|
143
|
+
can be `isPartOf` both its `WebPage` and the `Blog`. This lets agents understand
|
|
144
|
+
that a post belongs to a specific blog, not just a website. Use `Blog` when the
|
|
145
|
+
site has a distinct blog section; skip it for single-purpose blogs where the
|
|
146
|
+
blog _is_ the site.
|
|
147
|
+
|
|
148
|
+
**Rule:** Always use `{ '@id': ids.xxx }` to reference another entity. Never
|
|
149
|
+
inline the full entity inside another entity. The graph structure handles
|
|
150
|
+
resolution.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Piece builders reference
|
|
155
|
+
|
|
156
|
+
Every builder takes an input object and the `IdFactory`, and returns a
|
|
157
|
+
`GraphEntity` (a plain object with `@type` and usually `@id`).
|
|
158
|
+
|
|
159
|
+
### buildWebSite
|
|
160
|
+
|
|
161
|
+
Creates the site-wide `WebSite` entity. Include exactly once per graph.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
buildWebSite(
|
|
165
|
+
{
|
|
166
|
+
url: 'https://example.com/', // required — site root URL
|
|
167
|
+
name: 'My Site', // required — site name
|
|
168
|
+
description: 'A site about...', // optional
|
|
169
|
+
publisher: { '@id': ids.person }, // required — Person or Organization ref
|
|
170
|
+
about: { '@id': ids.person }, // optional — what this site is about
|
|
171
|
+
inLanguage: 'en-US', // optional — default content language
|
|
172
|
+
hasPart: { '@id': ids.navigation }, // optional — navigation ref
|
|
173
|
+
extra: {}, // optional — escape hatch for any schema.org property
|
|
174
|
+
},
|
|
175
|
+
ids,
|
|
176
|
+
);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Adding a SearchAction** (recommended for sites with search):
|
|
180
|
+
|
|
181
|
+
Use the `extra` field to add a `potentialAction` with a `SearchAction`. This
|
|
182
|
+
tells search engines and agents how to search your site:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
buildWebSite(
|
|
186
|
+
{
|
|
187
|
+
url: 'https://example.com/',
|
|
188
|
+
name: 'My Site',
|
|
189
|
+
publisher: { '@id': ids.person },
|
|
190
|
+
extra: {
|
|
191
|
+
potentialAction: {
|
|
192
|
+
'@type': 'SearchAction',
|
|
193
|
+
target: {
|
|
194
|
+
'@type': 'EntryPoint',
|
|
195
|
+
urlTemplate: 'https://example.com/?s={search_term_string}',
|
|
196
|
+
},
|
|
197
|
+
'query-input': {
|
|
198
|
+
'@type': 'PropertyValueSpecification',
|
|
199
|
+
valueRequired: true,
|
|
200
|
+
valueName: 'search_term_string',
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
ids,
|
|
206
|
+
);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
This is the pattern used by most WordPress sites and many other CMSes.
|
|
210
|
+
|
|
211
|
+
### buildPerson
|
|
212
|
+
|
|
213
|
+
Creates a `Person` entity. Typically the site owner or author.
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
buildPerson(
|
|
217
|
+
{
|
|
218
|
+
name: 'Jane Doe', // required
|
|
219
|
+
familyName: 'Doe', // optional
|
|
220
|
+
birthDate: '1990-01-15', // optional
|
|
221
|
+
gender: 'female', // optional
|
|
222
|
+
nationality: { '@id': ids.country('US') }, // optional
|
|
223
|
+
description: 'Software engineer...', // optional
|
|
224
|
+
jobTitle: 'Lead Engineer', // optional
|
|
225
|
+
knowsLanguage: ['en', 'es'], // optional
|
|
226
|
+
url: 'https://example.com/about/', // optional
|
|
227
|
+
image: { '@id': ids.personImage }, // optional — ref to ImageObject
|
|
228
|
+
sameAs: [
|
|
229
|
+
// optional — social/professional profiles
|
|
230
|
+
'https://twitter.com/janedoe',
|
|
231
|
+
'https://github.com/janedoe',
|
|
232
|
+
'https://linkedin.com/in/janedoe',
|
|
233
|
+
],
|
|
234
|
+
worksFor: [
|
|
235
|
+
// optional — EmployeeRole objects
|
|
236
|
+
{
|
|
237
|
+
'@type': 'EmployeeRole',
|
|
238
|
+
roleName: 'Lead Engineer',
|
|
239
|
+
startDate: '2022-01-01',
|
|
240
|
+
worksFor: { '@id': ids.organization('acme') },
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
extra: {}, // optional — escape hatch
|
|
244
|
+
},
|
|
245
|
+
ids,
|
|
246
|
+
);
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### buildOrganization
|
|
250
|
+
|
|
251
|
+
Creates an `Organization` or any subtype (`LocalBusiness`, `Restaurant`, etc.).
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
import type { LocalBusiness } from 'schema-dts';
|
|
255
|
+
|
|
256
|
+
// Basic organization
|
|
257
|
+
buildOrganization(
|
|
258
|
+
{
|
|
259
|
+
slug: 'acme', // required — stable slug for @id
|
|
260
|
+
name: 'Acme Corp', // required
|
|
261
|
+
url: 'https://acme.com/', // optional
|
|
262
|
+
description: 'We make things.', // optional
|
|
263
|
+
logo: 'https://acme.com/logo.png', // optional — URL string or ImageObject ref
|
|
264
|
+
sameAs: ['https://twitter.com/acme'], // optional
|
|
265
|
+
extra: {}, // optional — escape hatch
|
|
266
|
+
},
|
|
267
|
+
ids,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// Subtype (e.g. LocalBusiness, Restaurant, etc.)
|
|
271
|
+
buildOrganization<LocalBusiness>(
|
|
272
|
+
{
|
|
273
|
+
slug: 'my-restaurant',
|
|
274
|
+
name: 'Chez Example',
|
|
275
|
+
url: 'https://example.com/',
|
|
276
|
+
extra: {
|
|
277
|
+
address: {
|
|
278
|
+
'@type': 'PostalAddress',
|
|
279
|
+
streetAddress: '123 Main St',
|
|
280
|
+
addressLocality: 'Springfield',
|
|
281
|
+
addressRegion: 'IL',
|
|
282
|
+
postalCode: '62701',
|
|
283
|
+
addressCountry: 'US',
|
|
284
|
+
},
|
|
285
|
+
telephone: '+1-555-123-4567',
|
|
286
|
+
priceRange: '$$',
|
|
287
|
+
servesCuisine: 'French',
|
|
288
|
+
openingHoursSpecification: [
|
|
289
|
+
{
|
|
290
|
+
'@type': 'OpeningHoursSpecification',
|
|
291
|
+
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
|
292
|
+
opens: '11:00',
|
|
293
|
+
closes: '22:00',
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
ids,
|
|
299
|
+
'Restaurant',
|
|
300
|
+
);
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**The `subtype` parameter:** Pass the schema.org type name as the third argument
|
|
304
|
+
(e.g. `'Restaurant'`, `'LocalBusiness'`, `'Dentist'`). Use the matching
|
|
305
|
+
`schema-dts` type as the generic parameter for autocomplete on `extra`.
|
|
306
|
+
|
|
307
|
+
### buildWebPage
|
|
308
|
+
|
|
309
|
+
Creates a `WebPage`, `ProfilePage`, or `CollectionPage` entity.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
buildWebPage(
|
|
313
|
+
{
|
|
314
|
+
url: 'https://example.com/my-page/', // required — canonical URL (becomes @id)
|
|
315
|
+
name: 'My Page', // required — page title
|
|
316
|
+
isPartOf: { '@id': ids.website }, // required — WebSite ref
|
|
317
|
+
breadcrumb: { '@id': ids.breadcrumb(url) }, // optional — BreadcrumbList ref
|
|
318
|
+
inLanguage: 'en-US', // optional
|
|
319
|
+
datePublished: new Date('2026-01-15'), // optional — emitted as ISO string
|
|
320
|
+
dateModified: new Date('2026-03-01'), // optional
|
|
321
|
+
primaryImage: { '@id': ids.primaryImage(url) }, // optional — ImageObject ref
|
|
322
|
+
about: { '@id': ids.person }, // optional — for ProfilePage/homepage
|
|
323
|
+
copyrightHolder: { '@id': ids.person }, // optional — who holds the copyright
|
|
324
|
+
copyrightYear: 2026, // optional
|
|
325
|
+
copyrightNotice: '© 2026 Jane Doe.', // optional — human-readable copyright text
|
|
326
|
+
license: 'https://creativecommons.org/licenses/by/4.0/', // optional — license URL
|
|
327
|
+
isAccessibleForFree: true, // optional
|
|
328
|
+
potentialAction: [], // optional — defaults to ReadAction
|
|
329
|
+
extra: {}, // optional — escape hatch
|
|
330
|
+
},
|
|
331
|
+
ids,
|
|
332
|
+
'WebPage',
|
|
333
|
+
); // third param: 'WebPage' | 'ProfilePage' | 'CollectionPage'
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**When to use which type:**
|
|
337
|
+
|
|
338
|
+
- `WebPage` — Default. Blog posts, regular pages, product pages.
|
|
339
|
+
- `ProfilePage` — About pages, author profiles.
|
|
340
|
+
- `CollectionPage` — Blog listing, category archives, tag pages, portfolios.
|
|
341
|
+
|
|
342
|
+
### buildArticle
|
|
343
|
+
|
|
344
|
+
Creates an `Article` or any Article subtype (`BlogPosting`, `NewsArticle`,
|
|
345
|
+
etc.). Use for blog posts, news articles, tutorials.
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
buildArticle(
|
|
349
|
+
{
|
|
350
|
+
url: 'https://example.com/my-post/', // required — canonical URL
|
|
351
|
+
isPartOf: { '@id': ids.webPage(url) }, // required — enclosing WebPage ref
|
|
352
|
+
author: { '@id': ids.person }, // required — Person ref
|
|
353
|
+
publisher: { '@id': ids.person }, // required — Person or Organization ref
|
|
354
|
+
headline: 'My Post Title', // required
|
|
355
|
+
description: 'A brief summary...', // required
|
|
356
|
+
inLanguage: 'en-US', // optional
|
|
357
|
+
datePublished: new Date('2026-01-15'), // required
|
|
358
|
+
dateModified: new Date('2026-03-01'), // optional
|
|
359
|
+
image: { '@id': ids.primaryImage(url) }, // optional — ImageObject ref
|
|
360
|
+
about: { '@id': ids.person }, // optional — what this article is about
|
|
361
|
+
articleSection: 'Technology', // optional — top-level category
|
|
362
|
+
wordCount: 1500, // optional
|
|
363
|
+
articleBody: 'The full text...', // optional — plain text, max ~10K chars
|
|
364
|
+
extra: {}, // optional — escape hatch
|
|
365
|
+
},
|
|
366
|
+
ids,
|
|
367
|
+
'Article',
|
|
368
|
+
); // third param: 'Article' | 'BlogPosting' | 'NewsArticle' | 'TechArticle' | 'ScholarlyArticle' | 'Report'
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
**The `type` parameter:** Pass the schema.org type name as the third argument.
|
|
372
|
+
Defaults to `'Article'`. Use `'BlogPosting'` for blog posts, `'NewsArticle'`
|
|
373
|
+
for journalism, `'TechArticle'` for technical docs, `'ScholarlyArticle'` for
|
|
374
|
+
academic papers, or `'Report'` for data/research reports.
|
|
375
|
+
|
|
376
|
+
````
|
|
377
|
+
|
|
378
|
+
### buildBreadcrumbList
|
|
379
|
+
|
|
380
|
+
Creates a `BreadcrumbList` with nested `ListItem` entries.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
buildBreadcrumbList({
|
|
384
|
+
url: 'https://example.com/blog/my-post/', // required — page this belongs to
|
|
385
|
+
items: [ // required — ordered root-first
|
|
386
|
+
{ name: 'Home', url: 'https://example.com/' },
|
|
387
|
+
{ name: 'Blog', url: 'https://example.com/blog/' },
|
|
388
|
+
{ name: 'My Post', url: 'https://example.com/blog/my-post/' },
|
|
389
|
+
],
|
|
390
|
+
extra: {}, // optional
|
|
391
|
+
}, ids);
|
|
392
|
+
````
|
|
393
|
+
|
|
394
|
+
**Rules:**
|
|
395
|
+
|
|
396
|
+
- First item should be the homepage.
|
|
397
|
+
- Last item should be the current page.
|
|
398
|
+
- Order is root → leaf.
|
|
399
|
+
|
|
400
|
+
### buildImageObject
|
|
401
|
+
|
|
402
|
+
Creates an `ImageObject` entity.
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
// Page-specific image (e.g. blog post feature image)
|
|
406
|
+
buildImageObject(
|
|
407
|
+
{
|
|
408
|
+
pageUrl: 'https://example.com/my-post/', // one of pageUrl or id required
|
|
409
|
+
url: 'https://example.com/images/post.jpg', // required — image file URL
|
|
410
|
+
width: 1200, // required
|
|
411
|
+
height: 630, // required
|
|
412
|
+
inLanguage: 'en-US', // optional
|
|
413
|
+
caption: 'A photo of...', // optional
|
|
414
|
+
extra: {}, // optional
|
|
415
|
+
},
|
|
416
|
+
ids,
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
// Site-wide image (e.g. person photo, logo)
|
|
420
|
+
buildImageObject(
|
|
421
|
+
{
|
|
422
|
+
id: ids.personImage, // explicit @id override
|
|
423
|
+
url: 'https://example.com/joost.jpg',
|
|
424
|
+
width: 400,
|
|
425
|
+
height: 400,
|
|
426
|
+
},
|
|
427
|
+
ids,
|
|
428
|
+
);
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### buildVideoObject
|
|
432
|
+
|
|
433
|
+
Creates a `VideoObject` entity. Has built-in YouTube support.
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
buildVideoObject(
|
|
437
|
+
{
|
|
438
|
+
url: 'https://example.com/videos/my-talk/', // required — page URL
|
|
439
|
+
name: 'My Conference Talk', // required
|
|
440
|
+
description: 'A talk about...', // required
|
|
441
|
+
isPartOf: { '@id': ids.webPage(url) }, // required — enclosing WebPage ref
|
|
442
|
+
youtubeId: 'dQw4w9WgXcQ', // optional — auto-derives thumbnail + embed URLs
|
|
443
|
+
thumbnailUrl: '...', // optional — explicit override
|
|
444
|
+
embedUrl: '...', // optional — explicit override
|
|
445
|
+
uploadDate: new Date('2026-01-15'), // optional
|
|
446
|
+
duration: 'PT30M', // optional — ISO 8601
|
|
447
|
+
transcript: 'Full transcript text...', // optional
|
|
448
|
+
extra: {}, // optional
|
|
449
|
+
},
|
|
450
|
+
ids,
|
|
451
|
+
);
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
**YouTube convenience:** When `youtubeId` is provided:
|
|
455
|
+
|
|
456
|
+
- `thumbnailUrl` defaults to `https://img.youtube.com/vi/{id}/maxresdefault.jpg`
|
|
457
|
+
- `embedUrl` defaults to `https://www.youtube-nocookie.com/embed/{id}`
|
|
458
|
+
|
|
459
|
+
### buildSiteNavigationElement
|
|
460
|
+
|
|
461
|
+
Creates a `SiteNavigationElement` with nested items.
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
buildSiteNavigationElement(
|
|
465
|
+
{
|
|
466
|
+
name: 'Main navigation', // required
|
|
467
|
+
isPartOf: { '@id': ids.website }, // required — WebSite ref
|
|
468
|
+
items: [
|
|
469
|
+
// required — navigation links
|
|
470
|
+
{ name: 'Home', url: 'https://example.com/' },
|
|
471
|
+
{ name: 'Blog', url: 'https://example.com/blog/' },
|
|
472
|
+
{ name: 'About', url: 'https://example.com/about/' },
|
|
473
|
+
],
|
|
474
|
+
extra: {}, // optional
|
|
475
|
+
},
|
|
476
|
+
ids,
|
|
477
|
+
);
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### buildCustomPiece
|
|
481
|
+
|
|
482
|
+
Escape hatch for any schema.org type not covered by the built-in builders.
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
buildCustomPiece({
|
|
486
|
+
'@type': 'Recipe',
|
|
487
|
+
'@id': 'https://example.com/recipes/pasta/#recipe',
|
|
488
|
+
name: 'Simple Pasta',
|
|
489
|
+
author: { '@id': ids.person },
|
|
490
|
+
prepTime: 'PT15M',
|
|
491
|
+
cookTime: 'PT20M',
|
|
492
|
+
recipeIngredient: ['200g pasta', '2 cloves garlic', '...'],
|
|
493
|
+
recipeInstructions: [
|
|
494
|
+
{
|
|
495
|
+
'@type': 'HowToStep',
|
|
496
|
+
text: 'Boil the pasta.',
|
|
497
|
+
},
|
|
498
|
+
],
|
|
499
|
+
});
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
**When to use:** `Product`, `Event`, `Recipe`, `Course`, `SoftwareApplication`,
|
|
503
|
+
`VacationRental`, `FAQPage`, `HowTo`, or any other schema.org type. You're
|
|
504
|
+
responsible for correct structure; the builder just passes it through.
|
|
505
|
+
|
|
506
|
+
### assembleGraph
|
|
507
|
+
|
|
508
|
+
Wraps pieces in a `{ "@context": "https://schema.org", "@graph": [...] }`
|
|
509
|
+
envelope with first-wins deduplication by `@id`.
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
import { assembleGraph } from '@jdevalk/seo-graph-core';
|
|
513
|
+
|
|
514
|
+
const graph = assembleGraph([
|
|
515
|
+
websitePiece,
|
|
516
|
+
personPiece,
|
|
517
|
+
webPagePiece,
|
|
518
|
+
articlePiece,
|
|
519
|
+
breadcrumbPiece,
|
|
520
|
+
]);
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
**Always call this last.** It handles deduplication: if multiple pages produce
|
|
524
|
+
the same `WebSite` or `Person` entity (same `@id`), the first occurrence wins.
|
|
525
|
+
|
|
526
|
+
### deduplicateByGraphId
|
|
527
|
+
|
|
528
|
+
The dedup engine on its own, for custom assembly workflows.
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
import { deduplicateByGraphId } from '@jdevalk/seo-graph-core';
|
|
532
|
+
|
|
533
|
+
const unique = deduplicateByGraphId(allPieces);
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
## Site type recipes
|
|
539
|
+
|
|
540
|
+
Each recipe shows which pieces to include for a given page type. Copy the
|
|
541
|
+
pattern, adjust the data. Every recipe assumes you've already created an
|
|
542
|
+
`IdFactory` with `makeIds()`.
|
|
543
|
+
|
|
544
|
+
### Personal blog
|
|
545
|
+
|
|
546
|
+
The most common case. A single-author blog with posts, categories, and an
|
|
547
|
+
about page.
|
|
548
|
+
|
|
549
|
+
**For every page** (site-wide entities):
|
|
550
|
+
|
|
551
|
+
- `buildWebSite` — publisher points to Person
|
|
552
|
+
- `buildPerson` — the blog author
|
|
553
|
+
- `buildImageObject` — person's profile photo (use `id: ids.personImage`)
|
|
554
|
+
- `buildCustomPiece` — a `Blog` entity representing the blog as a publication
|
|
555
|
+
|
|
556
|
+
The `Blog` entity is a `CreativeWork` that represents the blog as a whole,
|
|
557
|
+
separate from the `WebSite`. Individual `BlogPosting` entries reference the
|
|
558
|
+
Blog via `isPartOf`. This is the pattern used by jonoalderson.com.
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
const blogId = `${siteUrl}/blog/#blog`;
|
|
562
|
+
|
|
563
|
+
// Include on every page as a site-wide entity
|
|
564
|
+
buildCustomPiece({
|
|
565
|
+
'@type': 'Blog',
|
|
566
|
+
'@id': blogId,
|
|
567
|
+
name: 'My Blog',
|
|
568
|
+
description: 'Thoughts on web development and the open web.',
|
|
569
|
+
url: `${siteUrl}/blog/`,
|
|
570
|
+
publisher: { '@id': ids.person },
|
|
571
|
+
inLanguage: 'en-US',
|
|
572
|
+
}),
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
**Blog post** (`/blog/my-post/`):
|
|
576
|
+
|
|
577
|
+
Use `BlogPosting` instead of `Article` and link it to the Blog:
|
|
578
|
+
|
|
579
|
+
```ts
|
|
580
|
+
const blogId = `${siteUrl}/blog/#blog`;
|
|
581
|
+
|
|
582
|
+
const pieces = [
|
|
583
|
+
buildWebSite({ url: siteUrl, name: 'My Blog', publisher: { '@id': ids.person } }, ids),
|
|
584
|
+
buildPerson({ name: 'Jane Doe', url: aboutUrl, image: { '@id': ids.personImage }, sameAs: [...] }, ids),
|
|
585
|
+
buildImageObject({ id: ids.personImage, url: profilePhotoUrl, width: 400, height: 400 }, ids),
|
|
586
|
+
buildCustomPiece({
|
|
587
|
+
'@type': 'Blog',
|
|
588
|
+
'@id': blogId,
|
|
589
|
+
name: 'My Blog',
|
|
590
|
+
url: `${siteUrl}/blog/`,
|
|
591
|
+
publisher: { '@id': ids.person },
|
|
592
|
+
}),
|
|
593
|
+
buildWebPage({ url, name: title, isPartOf: { '@id': ids.website }, breadcrumb: { '@id': ids.breadcrumb(url) }, datePublished, dateModified, primaryImage: { '@id': ids.primaryImage(url) } }, ids),
|
|
594
|
+
buildArticle({
|
|
595
|
+
url,
|
|
596
|
+
headline: title,
|
|
597
|
+
description,
|
|
598
|
+
datePublished,
|
|
599
|
+
dateModified,
|
|
600
|
+
author: { '@id': ids.person },
|
|
601
|
+
publisher: { '@id': ids.person },
|
|
602
|
+
isPartOf: { '@id': ids.webPage(url) },
|
|
603
|
+
image: { '@id': ids.primaryImage(url) },
|
|
604
|
+
articleSection: category,
|
|
605
|
+
wordCount,
|
|
606
|
+
extra: { isPartOf: [{ '@id': ids.webPage(url) }, { '@id': blogId }] },
|
|
607
|
+
}, ids, 'BlogPosting'),
|
|
608
|
+
buildBreadcrumbList({ url, items: [{ name: 'Home', url: siteUrl }, { name: 'Blog', url: blogUrl }, { name: title, url }] }, ids),
|
|
609
|
+
buildImageObject({ pageUrl: url, url: featureImageUrl, width: 1200, height: 630 }, ids),
|
|
610
|
+
];
|
|
611
|
+
const graph = assembleGraph(pieces);
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
**Note:** The `extra.isPartOf` override replaces the default `isPartOf` to link
|
|
615
|
+
the posting to both the `WebPage` and the `Blog`. If you don't need the `Blog`
|
|
616
|
+
link, just use `isPartOf: { '@id': ids.webPage(url) }` directly.
|
|
617
|
+
|
|
618
|
+
**Blog listing** (`/blog/`):
|
|
619
|
+
|
|
620
|
+
```ts
|
|
621
|
+
const pieces = [
|
|
622
|
+
// ...site-wide entities (including Blog)...
|
|
623
|
+
buildWebPage(
|
|
624
|
+
{
|
|
625
|
+
url,
|
|
626
|
+
name: 'Blog',
|
|
627
|
+
isPartOf: { '@id': ids.website },
|
|
628
|
+
breadcrumb: { '@id': ids.breadcrumb(url) },
|
|
629
|
+
about: { '@id': blogId },
|
|
630
|
+
},
|
|
631
|
+
ids,
|
|
632
|
+
'CollectionPage',
|
|
633
|
+
),
|
|
634
|
+
buildBreadcrumbList(
|
|
635
|
+
{
|
|
636
|
+
url,
|
|
637
|
+
items: [
|
|
638
|
+
{ name: 'Home', url: siteUrl },
|
|
639
|
+
{ name: 'Blog', url },
|
|
640
|
+
],
|
|
641
|
+
},
|
|
642
|
+
ids,
|
|
643
|
+
),
|
|
644
|
+
];
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
**Category archive** (`/blog/category/tech/`):
|
|
648
|
+
|
|
649
|
+
```ts
|
|
650
|
+
const pieces = [
|
|
651
|
+
// ...site-wide entities...
|
|
652
|
+
buildWebPage(
|
|
653
|
+
{
|
|
654
|
+
url,
|
|
655
|
+
name: 'Technology',
|
|
656
|
+
isPartOf: { '@id': ids.website },
|
|
657
|
+
breadcrumb: { '@id': ids.breadcrumb(url) },
|
|
658
|
+
},
|
|
659
|
+
ids,
|
|
660
|
+
'CollectionPage',
|
|
661
|
+
),
|
|
662
|
+
buildBreadcrumbList(
|
|
663
|
+
{
|
|
664
|
+
url,
|
|
665
|
+
items: [
|
|
666
|
+
{ name: 'Home', url: siteUrl },
|
|
667
|
+
{ name: 'Blog', url: blogUrl },
|
|
668
|
+
{ name: 'Technology', url },
|
|
669
|
+
],
|
|
670
|
+
},
|
|
671
|
+
ids,
|
|
672
|
+
),
|
|
673
|
+
];
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
**About page** (`/about/`):
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
const pieces = [
|
|
680
|
+
// ...site-wide entities...
|
|
681
|
+
buildWebPage(
|
|
682
|
+
{ url, name: 'About Jane', isPartOf: { '@id': ids.website }, about: { '@id': ids.person } },
|
|
683
|
+
ids,
|
|
684
|
+
'ProfilePage',
|
|
685
|
+
),
|
|
686
|
+
];
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
**Homepage** (`/`):
|
|
690
|
+
|
|
691
|
+
```ts
|
|
692
|
+
const pieces = [
|
|
693
|
+
// ...site-wide entities...
|
|
694
|
+
buildWebPage(
|
|
695
|
+
{
|
|
696
|
+
url: siteUrl,
|
|
697
|
+
name: 'Jane Doe — My Blog',
|
|
698
|
+
isPartOf: { '@id': ids.website },
|
|
699
|
+
about: { '@id': ids.person },
|
|
700
|
+
},
|
|
701
|
+
ids,
|
|
702
|
+
'CollectionPage',
|
|
703
|
+
),
|
|
704
|
+
];
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
---
|
|
708
|
+
|
|
709
|
+
### Business / company blog
|
|
710
|
+
|
|
711
|
+
A multi-author blog owned by a company.
|
|
712
|
+
|
|
713
|
+
**Key difference from personal blog:** The `WebSite` publisher is an
|
|
714
|
+
`Organization`, not a `Person`. Individual authors are separate `Person`
|
|
715
|
+
entities.
|
|
716
|
+
|
|
717
|
+
```ts
|
|
718
|
+
const ids = makeIds({ siteUrl: 'https://acme.com' });
|
|
719
|
+
|
|
720
|
+
// Site-wide
|
|
721
|
+
const blogId = 'https://acme.com/blog/#blog';
|
|
722
|
+
const siteEntities = [
|
|
723
|
+
buildOrganization({ slug: 'acme', name: 'Acme Corp', url: 'https://acme.com/', logo: logoUrl, sameAs: [...] }, ids),
|
|
724
|
+
buildWebSite({ url: 'https://acme.com/', name: 'Acme Blog', publisher: { '@id': ids.organization('acme') } }, ids),
|
|
725
|
+
buildCustomPiece({
|
|
726
|
+
'@type': 'Blog',
|
|
727
|
+
'@id': blogId,
|
|
728
|
+
name: 'The Acme Blog',
|
|
729
|
+
url: 'https://acme.com/blog/',
|
|
730
|
+
publisher: { '@id': ids.organization('acme') },
|
|
731
|
+
}),
|
|
732
|
+
];
|
|
733
|
+
|
|
734
|
+
// Per blog post — author is a separate Person (not site-wide ids.person)
|
|
735
|
+
const authorId = 'https://acme.com/team/jane/#person';
|
|
736
|
+
const postPieces = [
|
|
737
|
+
...siteEntities,
|
|
738
|
+
buildCustomPiece({ '@type': 'Person', '@id': authorId, name: 'Jane Doe', url: 'https://acme.com/team/jane/' }),
|
|
739
|
+
buildWebPage({ url, name: title, isPartOf: { '@id': ids.website }, datePublished }, ids),
|
|
740
|
+
buildArticle({
|
|
741
|
+
url,
|
|
742
|
+
headline: title,
|
|
743
|
+
description,
|
|
744
|
+
datePublished,
|
|
745
|
+
author: { '@id': authorId },
|
|
746
|
+
publisher: { '@id': ids.organization('acme') },
|
|
747
|
+
isPartOf: { '@id': ids.webPage(url) },
|
|
748
|
+
extra: { isPartOf: [{ '@id': ids.webPage(url) }, { '@id': blogId }] },
|
|
749
|
+
}, ids, 'BlogPosting'),
|
|
750
|
+
buildBreadcrumbList({ url, items: [{ name: 'Home', url: siteUrl }, { name: 'Blog', url: blogUrl }, { name: title, url }] }, ids),
|
|
751
|
+
];
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
---
|
|
755
|
+
|
|
756
|
+
### E-commerce / product page
|
|
757
|
+
|
|
758
|
+
Use `buildCustomPiece` for `Product`. The core doesn't have a built-in product
|
|
759
|
+
builder because product schemas vary wildly.
|
|
760
|
+
|
|
761
|
+
**Simple product (single variant):**
|
|
762
|
+
|
|
763
|
+
```ts
|
|
764
|
+
const ids = makeIds({ siteUrl: 'https://shop.example.com' });
|
|
765
|
+
|
|
766
|
+
const pieces = [
|
|
767
|
+
buildOrganization({ slug: 'shop', name: 'Example Shop', url: siteUrl, logo: logoUrl }, ids),
|
|
768
|
+
buildWebSite(
|
|
769
|
+
{ url: siteUrl, name: 'Example Shop', publisher: { '@id': ids.organization('shop') } },
|
|
770
|
+
ids,
|
|
771
|
+
),
|
|
772
|
+
buildWebPage(
|
|
773
|
+
{
|
|
774
|
+
url,
|
|
775
|
+
name: productName,
|
|
776
|
+
isPartOf: { '@id': ids.website },
|
|
777
|
+
breadcrumb: { '@id': ids.breadcrumb(url) },
|
|
778
|
+
},
|
|
779
|
+
ids,
|
|
780
|
+
),
|
|
781
|
+
buildBreadcrumbList(
|
|
782
|
+
{
|
|
783
|
+
url,
|
|
784
|
+
items: [
|
|
785
|
+
{ name: 'Home', url: siteUrl },
|
|
786
|
+
{ name: 'Shoes', url: categoryUrl },
|
|
787
|
+
{ name: productName, url },
|
|
788
|
+
],
|
|
789
|
+
},
|
|
790
|
+
ids,
|
|
791
|
+
),
|
|
792
|
+
buildCustomPiece({
|
|
793
|
+
'@type': 'Product',
|
|
794
|
+
'@id': `${url}#product`,
|
|
795
|
+
name: productName,
|
|
796
|
+
description: productDescription,
|
|
797
|
+
image: productImageUrl,
|
|
798
|
+
brand: { '@type': 'Brand', name: 'Nike' },
|
|
799
|
+
sku: 'ABC123',
|
|
800
|
+
offers: {
|
|
801
|
+
'@type': 'Offer',
|
|
802
|
+
price: 99.99,
|
|
803
|
+
priceCurrency: 'USD',
|
|
804
|
+
availability: 'https://schema.org/InStock',
|
|
805
|
+
url,
|
|
806
|
+
seller: { '@id': ids.organization('shop') },
|
|
807
|
+
},
|
|
808
|
+
aggregateRating: {
|
|
809
|
+
'@type': 'AggregateRating',
|
|
810
|
+
ratingValue: 4.5,
|
|
811
|
+
reviewCount: 42,
|
|
812
|
+
},
|
|
813
|
+
potentialAction: {
|
|
814
|
+
'@type': 'BuyAction',
|
|
815
|
+
target: {
|
|
816
|
+
'@type': 'EntryPoint',
|
|
817
|
+
urlTemplate: 'https://shop.example.com/cart/add/{sku}',
|
|
818
|
+
},
|
|
819
|
+
seller: { '@id': ids.organization('shop') },
|
|
820
|
+
},
|
|
821
|
+
}),
|
|
822
|
+
];
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
**Product with variants** (sizes, colors — see meta.com for a live example):
|
|
826
|
+
|
|
827
|
+
When a product has multiple variants (e.g. sizes, colors), use `ProductGroup`
|
|
828
|
+
as the parent and individual `Product` entities for each variant:
|
|
829
|
+
|
|
830
|
+
```ts
|
|
831
|
+
const variants = [
|
|
832
|
+
{
|
|
833
|
+
sku: 'SHOE-BLK-10',
|
|
834
|
+
name: 'Running Shoe — Black, Size 10',
|
|
835
|
+
color: 'Black',
|
|
836
|
+
size: '10',
|
|
837
|
+
price: 99.99,
|
|
838
|
+
inStock: true,
|
|
839
|
+
},
|
|
840
|
+
{
|
|
841
|
+
sku: 'SHOE-WHT-10',
|
|
842
|
+
name: 'Running Shoe — White, Size 10',
|
|
843
|
+
color: 'White',
|
|
844
|
+
size: '10',
|
|
845
|
+
price: 99.99,
|
|
846
|
+
inStock: true,
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
sku: 'SHOE-BLK-11',
|
|
850
|
+
name: 'Running Shoe — Black, Size 11',
|
|
851
|
+
color: 'Black',
|
|
852
|
+
size: '11',
|
|
853
|
+
price: 99.99,
|
|
854
|
+
inStock: false,
|
|
855
|
+
},
|
|
856
|
+
];
|
|
857
|
+
|
|
858
|
+
const pieces = [
|
|
859
|
+
// ...site-wide + WebPage + BreadcrumbList...
|
|
860
|
+
buildCustomPiece({
|
|
861
|
+
'@type': 'ProductGroup',
|
|
862
|
+
'@id': `${url}#product-group`,
|
|
863
|
+
name: 'Running Shoe',
|
|
864
|
+
description: productDescription,
|
|
865
|
+
brand: { '@type': 'Brand', name: 'Nike' },
|
|
866
|
+
productGroupID: 'running-shoe',
|
|
867
|
+
variesBy: ['https://schema.org/color', 'https://schema.org/size'],
|
|
868
|
+
hasVariant: variants.map((v) => ({ '@id': `${url}#variant-${v.sku}` })),
|
|
869
|
+
}),
|
|
870
|
+
...variants.map((v) =>
|
|
871
|
+
buildCustomPiece({
|
|
872
|
+
'@type': 'Product',
|
|
873
|
+
'@id': `${url}#variant-${v.sku}`,
|
|
874
|
+
name: v.name,
|
|
875
|
+
sku: v.sku,
|
|
876
|
+
color: v.color,
|
|
877
|
+
size: v.size,
|
|
878
|
+
image: [productImageUrl],
|
|
879
|
+
offers: {
|
|
880
|
+
'@type': 'Offer',
|
|
881
|
+
price: v.price,
|
|
882
|
+
priceCurrency: 'USD',
|
|
883
|
+
availability: v.inStock
|
|
884
|
+
? 'https://schema.org/InStock'
|
|
885
|
+
: 'https://schema.org/OutOfStock',
|
|
886
|
+
url,
|
|
887
|
+
hasMerchantReturnPolicy: {
|
|
888
|
+
'@type': 'MerchantReturnPolicy',
|
|
889
|
+
merchantReturnDays: 30,
|
|
890
|
+
returnMethod: 'https://schema.org/ReturnByMail',
|
|
891
|
+
returnFees: 'https://schema.org/FreeReturn',
|
|
892
|
+
},
|
|
893
|
+
shippingDetails: {
|
|
894
|
+
'@type': 'OfferShippingDetails',
|
|
895
|
+
shippingRate: { '@type': 'MonetaryAmount', value: 0, currency: 'USD' },
|
|
896
|
+
shippingDestination: { '@type': 'DefinedRegion', addressCountry: 'US' },
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
}),
|
|
900
|
+
),
|
|
901
|
+
];
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
---
|
|
905
|
+
|
|
906
|
+
### Local business
|
|
907
|
+
|
|
908
|
+
A restaurant, dentist, shop, or any business with a physical location.
|
|
909
|
+
|
|
910
|
+
```ts
|
|
911
|
+
import type { Restaurant } from 'schema-dts';
|
|
912
|
+
|
|
913
|
+
const ids = makeIds({ siteUrl: 'https://chezexample.com' });
|
|
914
|
+
|
|
915
|
+
const pieces = [
|
|
916
|
+
buildOrganization<Restaurant>(
|
|
917
|
+
{
|
|
918
|
+
slug: 'chez-example',
|
|
919
|
+
name: 'Chez Example',
|
|
920
|
+
url: 'https://chezexample.com/',
|
|
921
|
+
logo: logoUrl,
|
|
922
|
+
sameAs: ['https://instagram.com/chezexample'],
|
|
923
|
+
extra: {
|
|
924
|
+
address: {
|
|
925
|
+
'@type': 'PostalAddress',
|
|
926
|
+
streetAddress: '123 Rue de la Paix',
|
|
927
|
+
addressLocality: 'Paris',
|
|
928
|
+
postalCode: '75002',
|
|
929
|
+
addressCountry: 'FR',
|
|
930
|
+
},
|
|
931
|
+
telephone: '+33-1-23-45-67-89',
|
|
932
|
+
priceRange: '$$$',
|
|
933
|
+
servesCuisine: 'French',
|
|
934
|
+
geo: {
|
|
935
|
+
'@type': 'GeoCoordinates',
|
|
936
|
+
latitude: 48.8698,
|
|
937
|
+
longitude: 2.3311,
|
|
938
|
+
},
|
|
939
|
+
openingHoursSpecification: [
|
|
940
|
+
{
|
|
941
|
+
'@type': 'OpeningHoursSpecification',
|
|
942
|
+
dayOfWeek: ['Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
|
|
943
|
+
opens: '12:00',
|
|
944
|
+
closes: '14:30',
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
'@type': 'OpeningHoursSpecification',
|
|
948
|
+
dayOfWeek: ['Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
|
|
949
|
+
opens: '19:00',
|
|
950
|
+
closes: '22:30',
|
|
951
|
+
},
|
|
952
|
+
],
|
|
953
|
+
},
|
|
954
|
+
},
|
|
955
|
+
ids,
|
|
956
|
+
'Restaurant',
|
|
957
|
+
),
|
|
958
|
+
buildWebSite(
|
|
959
|
+
{
|
|
960
|
+
url: siteUrl,
|
|
961
|
+
name: 'Chez Example',
|
|
962
|
+
publisher: { '@id': ids.organization('chez-example') },
|
|
963
|
+
},
|
|
964
|
+
ids,
|
|
965
|
+
),
|
|
966
|
+
buildWebPage(
|
|
967
|
+
{
|
|
968
|
+
url: siteUrl,
|
|
969
|
+
name: 'Chez Example — French Restaurant in Paris',
|
|
970
|
+
isPartOf: { '@id': ids.website },
|
|
971
|
+
},
|
|
972
|
+
ids,
|
|
973
|
+
),
|
|
974
|
+
];
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
---
|
|
978
|
+
|
|
979
|
+
### Portfolio / agency
|
|
980
|
+
|
|
981
|
+
A freelancer or agency showcasing work.
|
|
982
|
+
|
|
983
|
+
```ts
|
|
984
|
+
const ids = makeIds({ siteUrl: 'https://janedoe.design' });
|
|
985
|
+
|
|
986
|
+
// Homepage — CollectionPage showcasing work
|
|
987
|
+
const pieces = [
|
|
988
|
+
buildPerson(
|
|
989
|
+
{
|
|
990
|
+
name: 'Jane Doe',
|
|
991
|
+
jobTitle: 'Product Designer',
|
|
992
|
+
url: siteUrl,
|
|
993
|
+
image: { '@id': ids.personImage },
|
|
994
|
+
sameAs: [dribbble, linkedin],
|
|
995
|
+
},
|
|
996
|
+
ids,
|
|
997
|
+
),
|
|
998
|
+
buildImageObject({ id: ids.personImage, url: headshot, width: 400, height: 400 }, ids),
|
|
999
|
+
buildWebSite({ url: siteUrl, name: 'Jane Doe Design', publisher: { '@id': ids.person } }, ids),
|
|
1000
|
+
buildWebPage(
|
|
1001
|
+
{
|
|
1002
|
+
url: siteUrl,
|
|
1003
|
+
name: 'Jane Doe — Product Designer',
|
|
1004
|
+
isPartOf: { '@id': ids.website },
|
|
1005
|
+
about: { '@id': ids.person },
|
|
1006
|
+
},
|
|
1007
|
+
ids,
|
|
1008
|
+
'CollectionPage',
|
|
1009
|
+
),
|
|
1010
|
+
];
|
|
1011
|
+
|
|
1012
|
+
// Individual project page
|
|
1013
|
+
const projectPieces = [
|
|
1014
|
+
// ...site-wide entities...
|
|
1015
|
+
buildWebPage(
|
|
1016
|
+
{
|
|
1017
|
+
url,
|
|
1018
|
+
name: projectTitle,
|
|
1019
|
+
isPartOf: { '@id': ids.website },
|
|
1020
|
+
breadcrumb: { '@id': ids.breadcrumb(url) },
|
|
1021
|
+
datePublished,
|
|
1022
|
+
},
|
|
1023
|
+
ids,
|
|
1024
|
+
),
|
|
1025
|
+
buildArticle(
|
|
1026
|
+
{
|
|
1027
|
+
url,
|
|
1028
|
+
isPartOf: { '@id': ids.webPage(url) },
|
|
1029
|
+
author: { '@id': ids.person },
|
|
1030
|
+
publisher: { '@id': ids.person },
|
|
1031
|
+
headline: projectTitle,
|
|
1032
|
+
description,
|
|
1033
|
+
datePublished,
|
|
1034
|
+
},
|
|
1035
|
+
ids,
|
|
1036
|
+
),
|
|
1037
|
+
buildBreadcrumbList(
|
|
1038
|
+
{
|
|
1039
|
+
url,
|
|
1040
|
+
items: [
|
|
1041
|
+
{ name: 'Home', url: siteUrl },
|
|
1042
|
+
{ name: 'Work', url: workUrl },
|
|
1043
|
+
{ name: projectTitle, url },
|
|
1044
|
+
],
|
|
1045
|
+
},
|
|
1046
|
+
ids,
|
|
1047
|
+
),
|
|
1048
|
+
];
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
---
|
|
1052
|
+
|
|
1053
|
+
### Documentation site
|
|
1054
|
+
|
|
1055
|
+
A docs site for a software project or API.
|
|
1056
|
+
|
|
1057
|
+
```ts
|
|
1058
|
+
const ids = makeIds({ siteUrl: 'https://docs.example.com' });
|
|
1059
|
+
|
|
1060
|
+
const pieces = [
|
|
1061
|
+
buildOrganization(
|
|
1062
|
+
{ slug: 'example', name: 'Example Inc', url: 'https://example.com/', logo: logoUrl },
|
|
1063
|
+
ids,
|
|
1064
|
+
),
|
|
1065
|
+
buildWebSite(
|
|
1066
|
+
{
|
|
1067
|
+
url: siteUrl,
|
|
1068
|
+
name: 'Example Docs',
|
|
1069
|
+
publisher: { '@id': ids.organization('example') },
|
|
1070
|
+
description: 'Documentation for Example SDK',
|
|
1071
|
+
},
|
|
1072
|
+
ids,
|
|
1073
|
+
),
|
|
1074
|
+
buildWebPage(
|
|
1075
|
+
{
|
|
1076
|
+
url,
|
|
1077
|
+
name: pageTitle,
|
|
1078
|
+
isPartOf: { '@id': ids.website },
|
|
1079
|
+
breadcrumb: { '@id': ids.breadcrumb(url) },
|
|
1080
|
+
dateModified,
|
|
1081
|
+
},
|
|
1082
|
+
ids,
|
|
1083
|
+
),
|
|
1084
|
+
buildBreadcrumbList(
|
|
1085
|
+
{
|
|
1086
|
+
url,
|
|
1087
|
+
items: [
|
|
1088
|
+
{ name: 'Docs', url: siteUrl },
|
|
1089
|
+
{ name: 'Guides', url: guidesUrl },
|
|
1090
|
+
{ name: pageTitle, url },
|
|
1091
|
+
],
|
|
1092
|
+
},
|
|
1093
|
+
ids,
|
|
1094
|
+
),
|
|
1095
|
+
];
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
For docs, `Article` is optional. Many documentation pages are better served by
|
|
1099
|
+
just `WebPage` + `BreadcrumbList`. Add `Article` only for tutorial-style content
|
|
1100
|
+
with a clear author and publish date.
|
|
1101
|
+
|
|
1102
|
+
---
|
|
1103
|
+
|
|
1104
|
+
### Podcast / video site
|
|
1105
|
+
|
|
1106
|
+
Just as `Blog` is a container for `BlogPosting`, `PodcastSeries` is a
|
|
1107
|
+
container for `PodcastEpisode`. Include the series as a site-wide entity.
|
|
1108
|
+
|
|
1109
|
+
**Video podcast (YouTube-based):**
|
|
1110
|
+
|
|
1111
|
+
```ts
|
|
1112
|
+
const ids = makeIds({ siteUrl: 'https://podcast.example.com' });
|
|
1113
|
+
const seriesId = `${siteUrl}#podcast-series`;
|
|
1114
|
+
|
|
1115
|
+
// Episode page
|
|
1116
|
+
const pieces = [
|
|
1117
|
+
buildPerson({ name: 'Host Name', url: aboutUrl, image: { '@id': ids.personImage } }, ids),
|
|
1118
|
+
buildImageObject({ id: ids.personImage, url: hostPhotoUrl, width: 400, height: 400 }, ids),
|
|
1119
|
+
buildWebSite({ url: siteUrl, name: 'My Podcast', publisher: { '@id': ids.person } }, ids),
|
|
1120
|
+
buildCustomPiece({
|
|
1121
|
+
'@type': 'PodcastSeries',
|
|
1122
|
+
'@id': seriesId,
|
|
1123
|
+
name: 'My Podcast',
|
|
1124
|
+
description: 'A weekly show about...',
|
|
1125
|
+
url: siteUrl,
|
|
1126
|
+
author: { '@id': ids.person },
|
|
1127
|
+
publisher: { '@id': ids.person },
|
|
1128
|
+
inLanguage: 'en-US',
|
|
1129
|
+
webFeed: `${siteUrl}feed.xml`,
|
|
1130
|
+
}),
|
|
1131
|
+
buildWebPage(
|
|
1132
|
+
{
|
|
1133
|
+
url,
|
|
1134
|
+
name: episodeTitle,
|
|
1135
|
+
isPartOf: { '@id': ids.website },
|
|
1136
|
+
breadcrumb: { '@id': ids.breadcrumb(url) },
|
|
1137
|
+
datePublished,
|
|
1138
|
+
},
|
|
1139
|
+
ids,
|
|
1140
|
+
),
|
|
1141
|
+
buildVideoObject(
|
|
1142
|
+
{
|
|
1143
|
+
url,
|
|
1144
|
+
name: episodeTitle,
|
|
1145
|
+
description: episodeDescription,
|
|
1146
|
+
isPartOf: { '@id': ids.webPage(url) },
|
|
1147
|
+
youtubeId,
|
|
1148
|
+
uploadDate: publishDate,
|
|
1149
|
+
duration: 'PT45M',
|
|
1150
|
+
transcript,
|
|
1151
|
+
},
|
|
1152
|
+
ids,
|
|
1153
|
+
),
|
|
1154
|
+
buildBreadcrumbList(
|
|
1155
|
+
{
|
|
1156
|
+
url,
|
|
1157
|
+
items: [
|
|
1158
|
+
{ name: 'Home', url: siteUrl },
|
|
1159
|
+
{ name: 'Episodes', url: episodesUrl },
|
|
1160
|
+
{ name: episodeTitle, url },
|
|
1161
|
+
],
|
|
1162
|
+
},
|
|
1163
|
+
ids,
|
|
1164
|
+
),
|
|
1165
|
+
];
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
**Audio-only podcast:**
|
|
1169
|
+
|
|
1170
|
+
Use `PodcastEpisode` linked to the `PodcastSeries`:
|
|
1171
|
+
|
|
1172
|
+
```ts
|
|
1173
|
+
const seriesId = `${siteUrl}#podcast-series`;
|
|
1174
|
+
|
|
1175
|
+
const pieces = [
|
|
1176
|
+
// ...site-wide entities including PodcastSeries...
|
|
1177
|
+
buildWebPage({ url, name: episodeTitle, isPartOf: { '@id': ids.website }, datePublished }, ids),
|
|
1178
|
+
buildCustomPiece({
|
|
1179
|
+
'@type': 'PodcastEpisode',
|
|
1180
|
+
'@id': `${url}#episode`,
|
|
1181
|
+
name: episodeTitle,
|
|
1182
|
+
description: episodeDescription,
|
|
1183
|
+
url,
|
|
1184
|
+
datePublished: publishDate.toISOString(),
|
|
1185
|
+
duration: 'PT45M',
|
|
1186
|
+
episodeNumber: 42,
|
|
1187
|
+
partOfSeries: { '@id': seriesId },
|
|
1188
|
+
associatedMedia: {
|
|
1189
|
+
'@type': 'MediaObject',
|
|
1190
|
+
contentUrl: mp3Url,
|
|
1191
|
+
encodingFormat: 'audio/mpeg',
|
|
1192
|
+
duration: 'PT45M',
|
|
1193
|
+
},
|
|
1194
|
+
author: { '@id': ids.person },
|
|
1195
|
+
}),
|
|
1196
|
+
buildBreadcrumbList(
|
|
1197
|
+
{
|
|
1198
|
+
url,
|
|
1199
|
+
items: [
|
|
1200
|
+
{ name: 'Home', url: siteUrl },
|
|
1201
|
+
{ name: 'Episodes', url: episodesUrl },
|
|
1202
|
+
{ name: episodeTitle, url },
|
|
1203
|
+
],
|
|
1204
|
+
},
|
|
1205
|
+
ids,
|
|
1206
|
+
),
|
|
1207
|
+
];
|
|
1208
|
+
```
|
|
1209
|
+
|
|
1210
|
+
**Podcast listing page** (`/episodes/`):
|
|
1211
|
+
|
|
1212
|
+
```ts
|
|
1213
|
+
const pieces = [
|
|
1214
|
+
// ...site-wide entities...
|
|
1215
|
+
buildWebPage(
|
|
1216
|
+
{ url, name: 'Episodes', isPartOf: { '@id': ids.website }, about: { '@id': seriesId } },
|
|
1217
|
+
ids,
|
|
1218
|
+
'CollectionPage',
|
|
1219
|
+
),
|
|
1220
|
+
];
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
---
|
|
1224
|
+
|
|
1225
|
+
### Vacation rental / accommodation
|
|
1226
|
+
|
|
1227
|
+
```ts
|
|
1228
|
+
const ids = makeIds({ siteUrl: 'https://myhouse.example.com' });
|
|
1229
|
+
|
|
1230
|
+
const pieces = [
|
|
1231
|
+
buildPerson({ name: 'Owner Name', url: siteUrl }, ids),
|
|
1232
|
+
buildWebSite({ url: siteUrl, name: 'Villa Example', publisher: { '@id': ids.person } }, ids),
|
|
1233
|
+
buildWebPage(
|
|
1234
|
+
{
|
|
1235
|
+
url: siteUrl,
|
|
1236
|
+
name: 'Villa Example — Holiday Home in Tuscany',
|
|
1237
|
+
isPartOf: { '@id': ids.website },
|
|
1238
|
+
},
|
|
1239
|
+
ids,
|
|
1240
|
+
),
|
|
1241
|
+
buildCustomPiece({
|
|
1242
|
+
'@type': 'VacationRental',
|
|
1243
|
+
'@id': `${siteUrl}#rental`,
|
|
1244
|
+
name: 'Villa Example',
|
|
1245
|
+
description: 'A beautiful villa...',
|
|
1246
|
+
url: siteUrl,
|
|
1247
|
+
image: [heroImageUrl],
|
|
1248
|
+
address: {
|
|
1249
|
+
'@type': 'PostalAddress',
|
|
1250
|
+
addressLocality: 'Lucca',
|
|
1251
|
+
addressRegion: 'Tuscany',
|
|
1252
|
+
addressCountry: 'IT',
|
|
1253
|
+
},
|
|
1254
|
+
geo: {
|
|
1255
|
+
'@type': 'GeoCoordinates',
|
|
1256
|
+
latitude: 43.84,
|
|
1257
|
+
longitude: 10.5,
|
|
1258
|
+
},
|
|
1259
|
+
numberOfRooms: 4,
|
|
1260
|
+
occupancy: {
|
|
1261
|
+
'@type': 'QuantitativeValue',
|
|
1262
|
+
maxValue: 8,
|
|
1263
|
+
},
|
|
1264
|
+
amenityFeature: [
|
|
1265
|
+
{ '@type': 'LocationFeatureSpecification', name: 'Pool', value: true },
|
|
1266
|
+
{ '@type': 'LocationFeatureSpecification', name: 'WiFi', value: true },
|
|
1267
|
+
],
|
|
1268
|
+
potentialAction: {
|
|
1269
|
+
'@type': 'RentAction',
|
|
1270
|
+
target: {
|
|
1271
|
+
'@type': 'EntryPoint',
|
|
1272
|
+
urlTemplate:
|
|
1273
|
+
'https://myhouse.example.com/book?checkin={checkin}&checkout={checkout}&guests={guests}',
|
|
1274
|
+
},
|
|
1275
|
+
landlord: { '@id': ids.person },
|
|
1276
|
+
priceSpecification: {
|
|
1277
|
+
'@type': 'UnitPriceSpecification',
|
|
1278
|
+
price: 250,
|
|
1279
|
+
priceCurrency: 'EUR',
|
|
1280
|
+
unitCode: 'DAY',
|
|
1281
|
+
},
|
|
1282
|
+
},
|
|
1283
|
+
}),
|
|
1284
|
+
];
|
|
1285
|
+
```
|
|
1286
|
+
|
|
1287
|
+
---
|
|
1288
|
+
|
|
1289
|
+
### Recipe site
|
|
1290
|
+
|
|
1291
|
+
```ts
|
|
1292
|
+
const ids = makeIds({ siteUrl: 'https://recipes.example.com' });
|
|
1293
|
+
|
|
1294
|
+
const pieces = [
|
|
1295
|
+
// ...site-wide entities...
|
|
1296
|
+
buildWebPage(
|
|
1297
|
+
{
|
|
1298
|
+
url,
|
|
1299
|
+
name: recipeName,
|
|
1300
|
+
isPartOf: { '@id': ids.website },
|
|
1301
|
+
breadcrumb: { '@id': ids.breadcrumb(url) },
|
|
1302
|
+
datePublished,
|
|
1303
|
+
},
|
|
1304
|
+
ids,
|
|
1305
|
+
),
|
|
1306
|
+
buildBreadcrumbList(
|
|
1307
|
+
{
|
|
1308
|
+
url,
|
|
1309
|
+
items: [
|
|
1310
|
+
{ name: 'Home', url: siteUrl },
|
|
1311
|
+
{ name: 'Italian', url: categoryUrl },
|
|
1312
|
+
{ name: recipeName, url },
|
|
1313
|
+
],
|
|
1314
|
+
},
|
|
1315
|
+
ids,
|
|
1316
|
+
),
|
|
1317
|
+
buildCustomPiece({
|
|
1318
|
+
'@type': 'Recipe',
|
|
1319
|
+
'@id': `${url}#recipe`,
|
|
1320
|
+
name: recipeName,
|
|
1321
|
+
description: recipeDescription,
|
|
1322
|
+
image: recipeImageUrl,
|
|
1323
|
+
author: { '@id': ids.person },
|
|
1324
|
+
datePublished: publishDate.toISOString(),
|
|
1325
|
+
prepTime: 'PT15M',
|
|
1326
|
+
cookTime: 'PT45M',
|
|
1327
|
+
totalTime: 'PT1H',
|
|
1328
|
+
recipeYield: '4 servings',
|
|
1329
|
+
recipeCategory: 'Main course',
|
|
1330
|
+
recipeCuisine: 'Italian',
|
|
1331
|
+
nutrition: {
|
|
1332
|
+
'@type': 'NutritionInformation',
|
|
1333
|
+
calories: '450 calories',
|
|
1334
|
+
},
|
|
1335
|
+
recipeIngredient: [
|
|
1336
|
+
'400g spaghetti',
|
|
1337
|
+
'200g guanciale',
|
|
1338
|
+
'4 egg yolks',
|
|
1339
|
+
'100g pecorino romano',
|
|
1340
|
+
],
|
|
1341
|
+
recipeInstructions: [
|
|
1342
|
+
{ '@type': 'HowToStep', text: 'Boil the spaghetti in salted water.' },
|
|
1343
|
+
{ '@type': 'HowToStep', text: 'Fry the guanciale until crispy.' },
|
|
1344
|
+
{ '@type': 'HowToStep', text: 'Mix egg yolks with pecorino.' },
|
|
1345
|
+
{ '@type': 'HowToStep', text: 'Combine and serve immediately.' },
|
|
1346
|
+
],
|
|
1347
|
+
}),
|
|
1348
|
+
];
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
---
|
|
1352
|
+
|
|
1353
|
+
### Event page
|
|
1354
|
+
|
|
1355
|
+
```ts
|
|
1356
|
+
buildCustomPiece({
|
|
1357
|
+
'@type': 'Event',
|
|
1358
|
+
'@id': `${url}#event`,
|
|
1359
|
+
name: 'JavaScript Conference 2026',
|
|
1360
|
+
description: 'Annual JavaScript conference...',
|
|
1361
|
+
startDate: '2026-09-15T09:00:00+02:00',
|
|
1362
|
+
endDate: '2026-09-17T18:00:00+02:00',
|
|
1363
|
+
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
|
1364
|
+
eventStatus: 'https://schema.org/EventScheduled',
|
|
1365
|
+
location: {
|
|
1366
|
+
'@type': 'Place',
|
|
1367
|
+
name: 'Congress Center',
|
|
1368
|
+
address: {
|
|
1369
|
+
'@type': 'PostalAddress',
|
|
1370
|
+
addressLocality: 'Amsterdam',
|
|
1371
|
+
addressCountry: 'NL',
|
|
1372
|
+
},
|
|
1373
|
+
},
|
|
1374
|
+
organizer: { '@id': ids.organization('organizer-slug') },
|
|
1375
|
+
offers: {
|
|
1376
|
+
'@type': 'Offer',
|
|
1377
|
+
price: 299,
|
|
1378
|
+
priceCurrency: 'EUR',
|
|
1379
|
+
availability: 'https://schema.org/InStock',
|
|
1380
|
+
url: ticketUrl,
|
|
1381
|
+
validFrom: '2026-01-01T00:00:00+01:00',
|
|
1382
|
+
},
|
|
1383
|
+
image: eventImageUrl,
|
|
1384
|
+
}),
|
|
1385
|
+
```
|
|
1386
|
+
|
|
1387
|
+
---
|
|
1388
|
+
|
|
1389
|
+
### SaaS / software product landing page
|
|
1390
|
+
|
|
1391
|
+
```ts
|
|
1392
|
+
const pieces = [
|
|
1393
|
+
buildOrganization({ slug: 'myapp', name: 'MyApp Inc', url: siteUrl, logo: logoUrl }, ids),
|
|
1394
|
+
buildWebSite(
|
|
1395
|
+
{ url: siteUrl, name: 'MyApp', publisher: { '@id': ids.organization('myapp') } },
|
|
1396
|
+
ids,
|
|
1397
|
+
),
|
|
1398
|
+
buildWebPage(
|
|
1399
|
+
{
|
|
1400
|
+
url: siteUrl,
|
|
1401
|
+
name: 'MyApp — Project Management for Teams',
|
|
1402
|
+
isPartOf: { '@id': ids.website },
|
|
1403
|
+
},
|
|
1404
|
+
ids,
|
|
1405
|
+
),
|
|
1406
|
+
buildCustomPiece({
|
|
1407
|
+
'@type': 'SoftwareApplication',
|
|
1408
|
+
'@id': `${siteUrl}#app`,
|
|
1409
|
+
name: 'MyApp',
|
|
1410
|
+
description: 'Project management for distributed teams.',
|
|
1411
|
+
url: siteUrl,
|
|
1412
|
+
applicationCategory: 'BusinessApplication',
|
|
1413
|
+
operatingSystem: 'Web',
|
|
1414
|
+
offers: {
|
|
1415
|
+
'@type': 'Offer',
|
|
1416
|
+
price: 0,
|
|
1417
|
+
priceCurrency: 'USD',
|
|
1418
|
+
description: 'Free tier available',
|
|
1419
|
+
},
|
|
1420
|
+
aggregateRating: {
|
|
1421
|
+
'@type': 'AggregateRating',
|
|
1422
|
+
ratingValue: 4.7,
|
|
1423
|
+
ratingCount: 1200,
|
|
1424
|
+
},
|
|
1425
|
+
potentialAction: {
|
|
1426
|
+
'@type': 'BuyAction',
|
|
1427
|
+
target: {
|
|
1428
|
+
'@type': 'EntryPoint',
|
|
1429
|
+
url: `${siteUrl}signup/`,
|
|
1430
|
+
},
|
|
1431
|
+
price: 0,
|
|
1432
|
+
priceCurrency: 'USD',
|
|
1433
|
+
description: 'Start free trial',
|
|
1434
|
+
},
|
|
1435
|
+
}),
|
|
1436
|
+
];
|
|
1437
|
+
```
|
|
1438
|
+
|
|
1439
|
+
---
|
|
1440
|
+
|
|
1441
|
+
### FAQ page
|
|
1442
|
+
|
|
1443
|
+
Combine `WebPage` with a `FAQPage` custom piece:
|
|
1444
|
+
|
|
1445
|
+
```ts
|
|
1446
|
+
const pieces = [
|
|
1447
|
+
// ...site-wide entities...
|
|
1448
|
+
buildWebPage(
|
|
1449
|
+
{ url, name: 'Frequently Asked Questions', isPartOf: { '@id': ids.website } },
|
|
1450
|
+
ids,
|
|
1451
|
+
),
|
|
1452
|
+
buildCustomPiece({
|
|
1453
|
+
'@type': 'FAQPage',
|
|
1454
|
+
'@id': `${url}#faq`,
|
|
1455
|
+
mainEntity: [
|
|
1456
|
+
{
|
|
1457
|
+
'@type': 'Question',
|
|
1458
|
+
name: 'How do I install seo-graph?',
|
|
1459
|
+
acceptedAnswer: {
|
|
1460
|
+
'@type': 'Answer',
|
|
1461
|
+
text: 'Run npm install @jdevalk/seo-graph-core',
|
|
1462
|
+
},
|
|
1463
|
+
},
|
|
1464
|
+
{
|
|
1465
|
+
'@type': 'Question',
|
|
1466
|
+
name: 'Does it work with Next.js?',
|
|
1467
|
+
acceptedAnswer: {
|
|
1468
|
+
'@type': 'Answer',
|
|
1469
|
+
text: 'Yes. Use @jdevalk/seo-graph-core directly. The Astro integration is Astro-only.',
|
|
1470
|
+
},
|
|
1471
|
+
},
|
|
1472
|
+
],
|
|
1473
|
+
}),
|
|
1474
|
+
];
|
|
1475
|
+
```
|
|
1476
|
+
|
|
1477
|
+
---
|
|
1478
|
+
|
|
1479
|
+
### Course / educational content
|
|
1480
|
+
|
|
1481
|
+
```ts
|
|
1482
|
+
buildCustomPiece({
|
|
1483
|
+
'@type': 'Course',
|
|
1484
|
+
'@id': `${url}#course`,
|
|
1485
|
+
name: 'Introduction to TypeScript',
|
|
1486
|
+
description: 'Learn TypeScript from scratch...',
|
|
1487
|
+
provider: { '@id': ids.organization('school-slug') },
|
|
1488
|
+
instructor: { '@id': ids.person },
|
|
1489
|
+
courseCode: 'TS-101',
|
|
1490
|
+
hasCourseInstance: {
|
|
1491
|
+
'@type': 'CourseInstance',
|
|
1492
|
+
courseMode: 'online',
|
|
1493
|
+
startDate: '2026-06-01',
|
|
1494
|
+
endDate: '2026-08-01',
|
|
1495
|
+
},
|
|
1496
|
+
offers: {
|
|
1497
|
+
'@type': 'Offer',
|
|
1498
|
+
price: 49,
|
|
1499
|
+
priceCurrency: 'USD',
|
|
1500
|
+
},
|
|
1501
|
+
}),
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
---
|
|
1505
|
+
|
|
1506
|
+
### News / magazine site
|
|
1507
|
+
|
|
1508
|
+
Same as a company blog, but consider using `NewsArticle` instead of `Article`:
|
|
1509
|
+
|
|
1510
|
+
```ts
|
|
1511
|
+
buildArticle({
|
|
1512
|
+
url,
|
|
1513
|
+
headline: title,
|
|
1514
|
+
description: excerpt,
|
|
1515
|
+
datePublished: publishDate,
|
|
1516
|
+
dateModified: modifiedDate,
|
|
1517
|
+
author: { '@id': authorPersonId },
|
|
1518
|
+
publisher: { '@id': ids.organization('newsroom') },
|
|
1519
|
+
isPartOf: { '@id': ids.webPage(url) },
|
|
1520
|
+
articleSection: section,
|
|
1521
|
+
image: { '@id': ids.primaryImage(url) },
|
|
1522
|
+
}, ids, 'NewsArticle'),
|
|
1523
|
+
```
|
|
1524
|
+
|
|
1525
|
+
---
|
|
1526
|
+
|
|
1527
|
+
## Trust and credibility signals
|
|
1528
|
+
|
|
1529
|
+
### publishingPrinciples
|
|
1530
|
+
|
|
1531
|
+
The `publishingPrinciples` property links to a document describing editorial
|
|
1532
|
+
policies. It can be applied to `Organization`, `Person`, or `CreativeWork`
|
|
1533
|
+
(including `Blog`). This is one of the strongest trust signals you can give
|
|
1534
|
+
search engines and AI agents about your content's credibility.
|
|
1535
|
+
|
|
1536
|
+
```ts
|
|
1537
|
+
// On a Person entity (personal blog)
|
|
1538
|
+
buildPerson({
|
|
1539
|
+
name: 'Jane Doe',
|
|
1540
|
+
url: aboutUrl,
|
|
1541
|
+
extra: {
|
|
1542
|
+
publishingPrinciples: `${siteUrl}/editorial-policy/`,
|
|
1543
|
+
},
|
|
1544
|
+
}, ids),
|
|
1545
|
+
|
|
1546
|
+
// On a Blog entity
|
|
1547
|
+
buildCustomPiece({
|
|
1548
|
+
'@type': 'Blog',
|
|
1549
|
+
'@id': blogId,
|
|
1550
|
+
name: 'My Blog',
|
|
1551
|
+
url: `${siteUrl}/blog/`,
|
|
1552
|
+
publisher: { '@id': ids.person },
|
|
1553
|
+
publishingPrinciples: `${siteUrl}/editorial-policy/`,
|
|
1554
|
+
}),
|
|
1555
|
+
|
|
1556
|
+
// On an Organization (news site, company blog)
|
|
1557
|
+
buildOrganization({
|
|
1558
|
+
slug: 'newsroom',
|
|
1559
|
+
name: 'The Daily Example',
|
|
1560
|
+
extra: {
|
|
1561
|
+
publishingPrinciples: `${siteUrl}/ethics/`,
|
|
1562
|
+
},
|
|
1563
|
+
}, ids),
|
|
1564
|
+
```
|
|
1565
|
+
|
|
1566
|
+
### Specialized policy sub-properties
|
|
1567
|
+
|
|
1568
|
+
For news and media organizations, schema.org has more specific sub-properties
|
|
1569
|
+
of `publishingPrinciples`:
|
|
1570
|
+
|
|
1571
|
+
```ts
|
|
1572
|
+
buildOrganization({
|
|
1573
|
+
slug: 'newsroom',
|
|
1574
|
+
name: 'The Daily Example',
|
|
1575
|
+
url: siteUrl,
|
|
1576
|
+
extra: {
|
|
1577
|
+
publishingPrinciples: `${siteUrl}/editorial-policy/`,
|
|
1578
|
+
correctionsPolicy: `${siteUrl}/corrections/`,
|
|
1579
|
+
verificationFactCheckingPolicy: `${siteUrl}/fact-checking/`,
|
|
1580
|
+
actionableFeedbackPolicy: `${siteUrl}/feedback/`,
|
|
1581
|
+
unnamedSourcesPolicy: `${siteUrl}/sources-policy/`,
|
|
1582
|
+
ownershipFundingInfo: `${siteUrl}/about/ownership/`,
|
|
1583
|
+
diversityStaffingReport: `${siteUrl}/diversity-report/`,
|
|
1584
|
+
masthead: `${siteUrl}/team/`,
|
|
1585
|
+
},
|
|
1586
|
+
}, ids),
|
|
1587
|
+
```
|
|
1588
|
+
|
|
1589
|
+
### When to use which
|
|
1590
|
+
|
|
1591
|
+
| Site type | Recommended properties |
|
|
1592
|
+
| ---------------------------------- | ----------------------------------------------------------------------------- |
|
|
1593
|
+
| Personal blog | `publishingPrinciples` on Person or Blog |
|
|
1594
|
+
| Company blog | `publishingPrinciples` on Organization |
|
|
1595
|
+
| News / magazine | All sub-properties (corrections, fact-checking, sources, ownership, masthead) |
|
|
1596
|
+
| Documentation site | `publishingPrinciples` on Organization (link to contribution guidelines) |
|
|
1597
|
+
| Any site with AI-generated content | `publishingPrinciples` (link to AI usage disclosure) |
|
|
1598
|
+
|
|
1599
|
+
**Practical advice:** You don't need all of these. Start with
|
|
1600
|
+
`publishingPrinciples` on your primary entity (Person or Organization). Add
|
|
1601
|
+
the sub-properties if you actually have those policy pages. Don't create empty
|
|
1602
|
+
policy pages just to fill the properties.
|
|
1603
|
+
|
|
1604
|
+
### Copyright, licensing, and access
|
|
1605
|
+
|
|
1606
|
+
`WebPage` (and all `CreativeWork` types, including `Article`, `BlogPosting`,
|
|
1607
|
+
`Blog`, and `Product`) supports copyright and licensing properties. These are
|
|
1608
|
+
increasingly important as AI agents need to understand what they can and can't
|
|
1609
|
+
do with your content.
|
|
1610
|
+
|
|
1611
|
+
**On WebPage:**
|
|
1612
|
+
|
|
1613
|
+
```ts
|
|
1614
|
+
buildWebPage({
|
|
1615
|
+
url,
|
|
1616
|
+
name: title,
|
|
1617
|
+
isPartOf: { '@id': ids.website },
|
|
1618
|
+
datePublished,
|
|
1619
|
+
copyrightHolder: { '@id': ids.person },
|
|
1620
|
+
copyrightYear: 2026,
|
|
1621
|
+
copyrightNotice: '© 2026 Jane Doe. All rights reserved.',
|
|
1622
|
+
license: 'https://creativecommons.org/licenses/by/4.0/',
|
|
1623
|
+
isAccessibleForFree: true,
|
|
1624
|
+
extra: {
|
|
1625
|
+
creditText: 'Jane Doe / janedoe.com',
|
|
1626
|
+
},
|
|
1627
|
+
}, ids),
|
|
1628
|
+
```
|
|
1629
|
+
|
|
1630
|
+
**On Article or BlogPosting:**
|
|
1631
|
+
|
|
1632
|
+
```ts
|
|
1633
|
+
buildArticle({
|
|
1634
|
+
url,
|
|
1635
|
+
headline: title,
|
|
1636
|
+
// ...other article properties...
|
|
1637
|
+
extra: {
|
|
1638
|
+
copyrightHolder: { '@id': ids.person },
|
|
1639
|
+
copyrightYear: 2026,
|
|
1640
|
+
license: 'https://creativecommons.org/licenses/by-sa/4.0/',
|
|
1641
|
+
},
|
|
1642
|
+
}, ids, 'BlogPosting'),
|
|
1643
|
+
```
|
|
1644
|
+
|
|
1645
|
+
**On WebSite (site-wide default):**
|
|
1646
|
+
|
|
1647
|
+
```ts
|
|
1648
|
+
buildWebSite({
|
|
1649
|
+
url: siteUrl,
|
|
1650
|
+
name: 'My Site',
|
|
1651
|
+
publisher: { '@id': ids.person },
|
|
1652
|
+
copyrightHolder: { '@id': ids.person },
|
|
1653
|
+
license: 'https://creativecommons.org/licenses/by/4.0/',
|
|
1654
|
+
}, ids),
|
|
1655
|
+
```
|
|
1656
|
+
|
|
1657
|
+
### Copyright and licensing properties reference
|
|
1658
|
+
|
|
1659
|
+
| Property | Type | Use for |
|
|
1660
|
+
| --------------------- | ---------------------- | --------------------------------------------------- |
|
|
1661
|
+
| `copyrightHolder` | Person or Organization | Who holds the copyright |
|
|
1662
|
+
| `copyrightYear` | Number | Year copyright was first asserted |
|
|
1663
|
+
| `copyrightNotice` | Text | Human-readable copyright text |
|
|
1664
|
+
| `license` | URL or CreativeWork | License that applies (CC, MIT, custom) |
|
|
1665
|
+
| `acquireLicensePage` | URL | Where to buy/request a license for reuse |
|
|
1666
|
+
| `creditText` | Text | How to credit when reusing (e.g. "Photo: Jane Doe") |
|
|
1667
|
+
| `isAccessibleForFree` | Boolean | Whether the content is free to access |
|
|
1668
|
+
| `conditionsOfAccess` | Text | Access conditions in natural language |
|
|
1669
|
+
|
|
1670
|
+
### When to use what
|
|
1671
|
+
|
|
1672
|
+
| Scenario | Properties to include |
|
|
1673
|
+
| ----------------------------------- | -------------------------------------------------------------------------------- |
|
|
1674
|
+
| Personal blog (all rights reserved) | `copyrightHolder`, `copyrightYear` |
|
|
1675
|
+
| Blog with Creative Commons license | `copyrightHolder`, `copyrightYear`, `license` |
|
|
1676
|
+
| Paywalled content | `isAccessibleForFree: false`, `conditionsOfAccess: 'Requires paid subscription'` |
|
|
1677
|
+
| Stock photography site | `copyrightHolder`, `license`, `acquireLicensePage`, `creditText` |
|
|
1678
|
+
| Open source docs (MIT/Apache) | `license` pointing to the license URL |
|
|
1679
|
+
| News with free + premium tiers | `isAccessibleForFree` per-article (true for free, false for premium) |
|
|
1680
|
+
| AI training opt-out signal | `copyrightNotice` + `license` with restrictive terms |
|
|
1681
|
+
|
|
1682
|
+
**Note on AI and licensing:** While `license` and `copyrightNotice` don't
|
|
1683
|
+
legally prevent AI training (that's what robots.txt, TDM headers, and
|
|
1684
|
+
contracts are for), they give agents clear metadata about your content's
|
|
1685
|
+
terms. An agent that respects licensing can check these properties before
|
|
1686
|
+
deciding how to use your content.
|
|
1687
|
+
|
|
1688
|
+
---
|
|
1689
|
+
|
|
1690
|
+
## Choosing the right Article subtype
|
|
1691
|
+
|
|
1692
|
+
`buildArticle` defaults to `@type: Article`, which is correct for most content.
|
|
1693
|
+
Pass a subtype as the third argument for more precise semantics:
|
|
1694
|
+
|
|
1695
|
+
| Type | When to use | Example |
|
|
1696
|
+
| ------------------ | --------------------------------------------------------- | ------------------------------- |
|
|
1697
|
+
| `Article` | Default. General articles, tutorials, guides. | "How to set up ESLint" |
|
|
1698
|
+
| `BlogPosting` | Personal blog posts, opinion pieces, diary-style entries. | "Why I switched to Astro" |
|
|
1699
|
+
| `NewsArticle` | News reporting, journalism, press releases. | "Google announces new protocol" |
|
|
1700
|
+
| `TechArticle` | Technical documentation, API guides, spec write-ups. | "WebSocket protocol deep dive" |
|
|
1701
|
+
| `ScholarlyArticle` | Academic papers, research publications. | "Effects of caching on TTFB" |
|
|
1702
|
+
| `Report` | Data reports, annual reviews, research findings. | "State of CSS 2026" |
|
|
1703
|
+
|
|
1704
|
+
```ts
|
|
1705
|
+
buildArticle(
|
|
1706
|
+
{
|
|
1707
|
+
url,
|
|
1708
|
+
headline: title,
|
|
1709
|
+
description: excerpt,
|
|
1710
|
+
datePublished: publishDate,
|
|
1711
|
+
dateModified: modifiedDate,
|
|
1712
|
+
author: { '@id': ids.person },
|
|
1713
|
+
publisher: { '@id': ids.person },
|
|
1714
|
+
isPartOf: { '@id': ids.webPage(url) },
|
|
1715
|
+
image: { '@id': ids.primaryImage(url) },
|
|
1716
|
+
articleSection: category,
|
|
1717
|
+
wordCount,
|
|
1718
|
+
articleBody: plainTextBody,
|
|
1719
|
+
},
|
|
1720
|
+
ids,
|
|
1721
|
+
'BlogPosting',
|
|
1722
|
+
);
|
|
1723
|
+
```
|
|
1724
|
+
|
|
1725
|
+
jonoalderson.com uses `BlogPosting` for all blog content. Most SEO plugins
|
|
1726
|
+
default to `Article`. Both are valid; `BlogPosting` is more semantically
|
|
1727
|
+
precise for personal blogs.
|
|
1728
|
+
|
|
1729
|
+
---
|
|
1730
|
+
|
|
1731
|
+
## Actions: telling agents what they can do
|
|
1732
|
+
|
|
1733
|
+
The `potentialAction` property on any entity tells search engines and AI agents
|
|
1734
|
+
_what actions can be performed_ and _where to go to perform them_. This is the
|
|
1735
|
+
mechanism that makes your schema truly agent-ready: an agent can read your
|
|
1736
|
+
graph, find a `BuyAction` on a Product, and navigate to the checkout URL.
|
|
1737
|
+
|
|
1738
|
+
### The TradeAction family
|
|
1739
|
+
|
|
1740
|
+
All commerce-related actions inherit from `TradeAction`:
|
|
1741
|
+
|
|
1742
|
+
| Action | Use for | Key extra property |
|
|
1743
|
+
| ---------------- | -------------------------------------- | ----------------------------- |
|
|
1744
|
+
| `BuyAction` | Direct purchase (add to cart, buy now) | `seller` |
|
|
1745
|
+
| `OrderAction` | Order for delivery | `deliveryMethod` |
|
|
1746
|
+
| `PreOrderAction` | Not yet available, reserve now | — |
|
|
1747
|
+
| `RentAction` | Vacation rentals, equipment, cars | `landlord`, `realEstateAgent` |
|
|
1748
|
+
| `QuoteAction` | Custom pricing, request a quote | — |
|
|
1749
|
+
| `SellAction` | Marketplace listings (seller-side) | `buyer` |
|
|
1750
|
+
| `PayAction` | Payment processing | — |
|
|
1751
|
+
| `TipAction` | Donations, tips, support | — |
|
|
1752
|
+
|
|
1753
|
+
### The pattern
|
|
1754
|
+
|
|
1755
|
+
Every action uses `target` with an `EntryPoint` to specify the URL where the
|
|
1756
|
+
action can be performed. The `urlTemplate` variant supports parameters:
|
|
1757
|
+
|
|
1758
|
+
```ts
|
|
1759
|
+
potentialAction: {
|
|
1760
|
+
'@type': 'BuyAction',
|
|
1761
|
+
target: {
|
|
1762
|
+
'@type': 'EntryPoint',
|
|
1763
|
+
urlTemplate: 'https://shop.example.com/cart/add/{sku}',
|
|
1764
|
+
// or just: url: 'https://shop.example.com/cart/add/ABC123',
|
|
1765
|
+
},
|
|
1766
|
+
}
|
|
1767
|
+
```
|
|
1768
|
+
|
|
1769
|
+
### Buying a product
|
|
1770
|
+
|
|
1771
|
+
Add to the `Product` or `ProductGroup` entity:
|
|
1772
|
+
|
|
1773
|
+
```ts
|
|
1774
|
+
buildCustomPiece({
|
|
1775
|
+
'@type': 'Product',
|
|
1776
|
+
'@id': `${url}#product`,
|
|
1777
|
+
name: productName,
|
|
1778
|
+
// ...other product properties...
|
|
1779
|
+
potentialAction: {
|
|
1780
|
+
'@type': 'BuyAction',
|
|
1781
|
+
target: {
|
|
1782
|
+
'@type': 'EntryPoint',
|
|
1783
|
+
urlTemplate: `https://shop.example.com/cart/add/{sku}`,
|
|
1784
|
+
},
|
|
1785
|
+
seller: { '@id': ids.organization('shop') },
|
|
1786
|
+
},
|
|
1787
|
+
}),
|
|
1788
|
+
```
|
|
1789
|
+
|
|
1790
|
+
### Pre-ordering a product
|
|
1791
|
+
|
|
1792
|
+
For products not yet available:
|
|
1793
|
+
|
|
1794
|
+
```ts
|
|
1795
|
+
potentialAction: {
|
|
1796
|
+
'@type': 'PreOrderAction',
|
|
1797
|
+
target: {
|
|
1798
|
+
'@type': 'EntryPoint',
|
|
1799
|
+
url: 'https://shop.example.com/pre-order/new-gadget',
|
|
1800
|
+
},
|
|
1801
|
+
description: 'Pre-order — ships March 2027',
|
|
1802
|
+
},
|
|
1803
|
+
```
|
|
1804
|
+
|
|
1805
|
+
### Ordering with delivery
|
|
1806
|
+
|
|
1807
|
+
When you need to specify how the product will be delivered:
|
|
1808
|
+
|
|
1809
|
+
```ts
|
|
1810
|
+
potentialAction: {
|
|
1811
|
+
'@type': 'OrderAction',
|
|
1812
|
+
target: {
|
|
1813
|
+
'@type': 'EntryPoint',
|
|
1814
|
+
url: `${url}checkout/`,
|
|
1815
|
+
},
|
|
1816
|
+
deliveryMethod: 'https://schema.org/ParcelService',
|
|
1817
|
+
},
|
|
1818
|
+
```
|
|
1819
|
+
|
|
1820
|
+
`deliveryMethod` values: `ParcelService`, `OnSitePickup`, `LockerDelivery`.
|
|
1821
|
+
|
|
1822
|
+
### Renting (vacation rental, equipment, cars)
|
|
1823
|
+
|
|
1824
|
+
Add to the `VacationRental`, `Product`, or `Car` entity:
|
|
1825
|
+
|
|
1826
|
+
```ts
|
|
1827
|
+
buildCustomPiece({
|
|
1828
|
+
'@type': 'VacationRental',
|
|
1829
|
+
'@id': `${siteUrl}#rental`,
|
|
1830
|
+
name: 'Villa Example',
|
|
1831
|
+
// ...other rental properties...
|
|
1832
|
+
potentialAction: {
|
|
1833
|
+
'@type': 'RentAction',
|
|
1834
|
+
target: {
|
|
1835
|
+
'@type': 'EntryPoint',
|
|
1836
|
+
urlTemplate: 'https://myhouse.example.com/book?checkin={checkin}&checkout={checkout}',
|
|
1837
|
+
},
|
|
1838
|
+
landlord: { '@id': ids.person },
|
|
1839
|
+
priceSpecification: {
|
|
1840
|
+
'@type': 'UnitPriceSpecification',
|
|
1841
|
+
price: 250,
|
|
1842
|
+
priceCurrency: 'EUR',
|
|
1843
|
+
unitCode: 'DAY',
|
|
1844
|
+
},
|
|
1845
|
+
},
|
|
1846
|
+
}),
|
|
1847
|
+
```
|
|
1848
|
+
|
|
1849
|
+
**URL template variables for rentals:** `{checkin}`, `{checkout}`, `{guests}`
|
|
1850
|
+
are conventional but not standardized. Use names that match your booking form's
|
|
1851
|
+
query parameters.
|
|
1852
|
+
|
|
1853
|
+
For rentals through an agency:
|
|
1854
|
+
|
|
1855
|
+
```ts
|
|
1856
|
+
potentialAction: {
|
|
1857
|
+
'@type': 'RentAction',
|
|
1858
|
+
target: {
|
|
1859
|
+
'@type': 'EntryPoint',
|
|
1860
|
+
url: 'https://bookingagency.com/listing/villa-example',
|
|
1861
|
+
},
|
|
1862
|
+
landlord: { '@id': ids.person },
|
|
1863
|
+
realEstateAgent: {
|
|
1864
|
+
'@type': 'RealEstateAgent',
|
|
1865
|
+
name: 'Tuscany Villas Agency',
|
|
1866
|
+
url: 'https://bookingagency.com/',
|
|
1867
|
+
},
|
|
1868
|
+
},
|
|
1869
|
+
```
|
|
1870
|
+
|
|
1871
|
+
### Requesting a quote
|
|
1872
|
+
|
|
1873
|
+
For services or products with custom pricing (B2B, consulting, configured
|
|
1874
|
+
products):
|
|
1875
|
+
|
|
1876
|
+
```ts
|
|
1877
|
+
potentialAction: {
|
|
1878
|
+
'@type': 'QuoteAction',
|
|
1879
|
+
target: {
|
|
1880
|
+
'@type': 'EntryPoint',
|
|
1881
|
+
url: 'https://agency.example.com/contact',
|
|
1882
|
+
},
|
|
1883
|
+
description: 'Request a project quote',
|
|
1884
|
+
},
|
|
1885
|
+
```
|
|
1886
|
+
|
|
1887
|
+
### Marketplace listings
|
|
1888
|
+
|
|
1889
|
+
Marketplaces often need both buy and make-offer actions:
|
|
1890
|
+
|
|
1891
|
+
```ts
|
|
1892
|
+
potentialAction: [
|
|
1893
|
+
{
|
|
1894
|
+
'@type': 'BuyAction',
|
|
1895
|
+
target: {
|
|
1896
|
+
'@type': 'EntryPoint',
|
|
1897
|
+
url: buyNowUrl,
|
|
1898
|
+
},
|
|
1899
|
+
seller: { '@id': sellerPersonId },
|
|
1900
|
+
price: 499,
|
|
1901
|
+
priceCurrency: 'USD',
|
|
1902
|
+
},
|
|
1903
|
+
{
|
|
1904
|
+
'@type': 'QuoteAction',
|
|
1905
|
+
target: {
|
|
1906
|
+
'@type': 'EntryPoint',
|
|
1907
|
+
url: makeOfferUrl,
|
|
1908
|
+
},
|
|
1909
|
+
description: 'Make an offer',
|
|
1910
|
+
},
|
|
1911
|
+
],
|
|
1912
|
+
```
|
|
1913
|
+
|
|
1914
|
+
### Donations and tips
|
|
1915
|
+
|
|
1916
|
+
For open source projects, creators, or nonprofits:
|
|
1917
|
+
|
|
1918
|
+
```ts
|
|
1919
|
+
potentialAction: {
|
|
1920
|
+
'@type': 'TipAction',
|
|
1921
|
+
target: {
|
|
1922
|
+
'@type': 'EntryPoint',
|
|
1923
|
+
url: 'https://example.com/donate',
|
|
1924
|
+
},
|
|
1925
|
+
description: 'Support this project',
|
|
1926
|
+
recipient: { '@id': ids.person },
|
|
1927
|
+
},
|
|
1928
|
+
```
|
|
1929
|
+
|
|
1930
|
+
### Combining actions with SearchAction
|
|
1931
|
+
|
|
1932
|
+
Many entities benefit from multiple actions. A WebSite typically has a
|
|
1933
|
+
`SearchAction`; the entities within it have trade actions:
|
|
1934
|
+
|
|
1935
|
+
```ts
|
|
1936
|
+
// WebSite: how to search
|
|
1937
|
+
buildWebSite({
|
|
1938
|
+
url: siteUrl,
|
|
1939
|
+
name: 'My Shop',
|
|
1940
|
+
publisher: { '@id': ids.organization('shop') },
|
|
1941
|
+
extra: {
|
|
1942
|
+
potentialAction: {
|
|
1943
|
+
'@type': 'SearchAction',
|
|
1944
|
+
target: {
|
|
1945
|
+
'@type': 'EntryPoint',
|
|
1946
|
+
urlTemplate: `${siteUrl}search?q={search_term_string}`,
|
|
1947
|
+
},
|
|
1948
|
+
'query-input': {
|
|
1949
|
+
'@type': 'PropertyValueSpecification',
|
|
1950
|
+
valueRequired: true,
|
|
1951
|
+
valueName: 'search_term_string',
|
|
1952
|
+
},
|
|
1953
|
+
},
|
|
1954
|
+
},
|
|
1955
|
+
}, ids),
|
|
1956
|
+
|
|
1957
|
+
// Product: how to buy
|
|
1958
|
+
buildCustomPiece({
|
|
1959
|
+
'@type': 'Product',
|
|
1960
|
+
'@id': `${url}#product`,
|
|
1961
|
+
name: productName,
|
|
1962
|
+
potentialAction: {
|
|
1963
|
+
'@type': 'BuyAction',
|
|
1964
|
+
target: { '@type': 'EntryPoint', url: addToCartUrl },
|
|
1965
|
+
seller: { '@id': ids.organization('shop') },
|
|
1966
|
+
},
|
|
1967
|
+
}),
|
|
1968
|
+
```
|
|
1969
|
+
|
|
1970
|
+
### When to use which action
|
|
1971
|
+
|
|
1972
|
+
| Scenario | Action | Why |
|
|
1973
|
+
| ------------------------------- | ------------------------------------------------------- | ------------------------------- |
|
|
1974
|
+
| E-commerce product, buy now | `BuyAction` | Direct purchase, immediate |
|
|
1975
|
+
| E-commerce product, add to cart | `BuyAction` | Still a buy intent |
|
|
1976
|
+
| Product not yet released | `PreOrderAction` | Signals future availability |
|
|
1977
|
+
| Physical goods with shipping | `OrderAction` + `deliveryMethod` | Delivery is part of the action |
|
|
1978
|
+
| Vacation rental booking | `RentAction` + `landlord` | Temporal use, not ownership |
|
|
1979
|
+
| Car rental | `RentAction` | Temporal use |
|
|
1980
|
+
| Equipment rental | `RentAction` | Temporal use |
|
|
1981
|
+
| Custom/B2B pricing | `QuoteAction` | Price not fixed |
|
|
1982
|
+
| Consulting services | `QuoteAction` | Scope-dependent pricing |
|
|
1983
|
+
| Marketplace: fixed price | `BuyAction` + `seller` | Direct from seller |
|
|
1984
|
+
| Marketplace: negotiable | `BuyAction` + `QuoteAction` | Both options available |
|
|
1985
|
+
| SaaS free trial | `BuyAction` with `price: 0` | Free is still a transaction |
|
|
1986
|
+
| Donations / support | `TipAction` + `recipient` | Voluntary, no product exchanged |
|
|
1987
|
+
| Subscription | `BuyAction` + `priceSpecification` with `billingPeriod` | Recurring purchase |
|
|
1988
|
+
|
|
1989
|
+
---
|
|
1990
|
+
|
|
1991
|
+
## Multi-type entities
|
|
1992
|
+
|
|
1993
|
+
An entity can have multiple `@type` values. This is useful when an entity
|
|
1994
|
+
legitimately belongs to more than one type:
|
|
1995
|
+
|
|
1996
|
+
```ts
|
|
1997
|
+
buildCustomPiece({
|
|
1998
|
+
'@type': ['Organization', 'Brand'],
|
|
1999
|
+
'@id': ids.organization('acme'),
|
|
2000
|
+
name: 'Acme',
|
|
2001
|
+
url: 'https://acme.com/',
|
|
2002
|
+
logo: {
|
|
2003
|
+
/* ... */
|
|
2004
|
+
},
|
|
2005
|
+
});
|
|
2006
|
+
```
|
|
2007
|
+
|
|
2008
|
+
This is appropriate for companies that are also consumer-facing brands.
|
|
2009
|
+
|
|
2010
|
+
Common multi-type combinations:
|
|
2011
|
+
|
|
2012
|
+
- `['Organization', 'Brand']` — Company with brand identity
|
|
2013
|
+
- `['LocalBusiness', 'Restaurant']` — Specific local business type
|
|
2014
|
+
- `['Person', 'Patient']` — Context-specific
|
|
2015
|
+
- `['WebPage', 'ItemPage']` — Product detail pages
|
|
2016
|
+
- `['WebPage', 'FAQPage']` — FAQ pages (alternative to separate FAQPage entity)
|
|
2017
|
+
|
|
2018
|
+
**Note:** When using multi-type with `buildOrganization`, pass the primary
|
|
2019
|
+
subtype as the third argument and add additional types via `extra`:
|
|
2020
|
+
|
|
2021
|
+
```ts
|
|
2022
|
+
buildOrganization(
|
|
2023
|
+
{
|
|
2024
|
+
slug: 'acme',
|
|
2025
|
+
name: 'Acme',
|
|
2026
|
+
extra: { '@type': ['Organization', 'Brand'] },
|
|
2027
|
+
},
|
|
2028
|
+
ids,
|
|
2029
|
+
);
|
|
2030
|
+
```
|
|
2031
|
+
|
|
2032
|
+
---
|
|
2033
|
+
|
|
2034
|
+
## Rich Organization patterns
|
|
2035
|
+
|
|
2036
|
+
For established businesses, a richer Organization entity improves knowledge
|
|
2037
|
+
graph representation. Here's the full pattern:
|
|
2038
|
+
|
|
2039
|
+
```ts
|
|
2040
|
+
buildOrganization(
|
|
2041
|
+
{
|
|
2042
|
+
slug: 'acme',
|
|
2043
|
+
name: 'Acme Corp',
|
|
2044
|
+
url: 'https://acme.com/',
|
|
2045
|
+
logo: 'https://acme.com/logo.png',
|
|
2046
|
+
description: 'We build developer tools.',
|
|
2047
|
+
sameAs: [
|
|
2048
|
+
'https://twitter.com/acme',
|
|
2049
|
+
'https://linkedin.com/company/acme',
|
|
2050
|
+
'https://github.com/acme',
|
|
2051
|
+
'https://en.wikipedia.org/wiki/Acme_Corp',
|
|
2052
|
+
],
|
|
2053
|
+
extra: {
|
|
2054
|
+
legalName: 'Acme Corp B.V.',
|
|
2055
|
+
foundingDate: '2015-03-01',
|
|
2056
|
+
founder: {
|
|
2057
|
+
'@type': 'Person',
|
|
2058
|
+
name: 'Jane Doe',
|
|
2059
|
+
sameAs: 'https://en.wikipedia.org/wiki/Jane_Doe',
|
|
2060
|
+
},
|
|
2061
|
+
numberOfEmployees: 45,
|
|
2062
|
+
slogan: 'Tools for the modern web',
|
|
2063
|
+
parentOrganization: {
|
|
2064
|
+
'@type': 'Organization',
|
|
2065
|
+
name: 'Parent Holdings Inc',
|
|
2066
|
+
url: 'https://parent.com/',
|
|
2067
|
+
},
|
|
2068
|
+
memberOf: {
|
|
2069
|
+
'@type': 'Organization',
|
|
2070
|
+
name: 'World Wide Web Consortium (W3C)',
|
|
2071
|
+
url: 'https://w3.org/',
|
|
2072
|
+
},
|
|
2073
|
+
address: {
|
|
2074
|
+
'@type': 'PostalAddress',
|
|
2075
|
+
streetAddress: '123 Tech Lane',
|
|
2076
|
+
addressLocality: 'Amsterdam',
|
|
2077
|
+
addressCountry: 'NL',
|
|
2078
|
+
},
|
|
2079
|
+
},
|
|
2080
|
+
},
|
|
2081
|
+
ids,
|
|
2082
|
+
);
|
|
2083
|
+
```
|
|
2084
|
+
|
|
2085
|
+
Include as much as is factually accurate. Don't fabricate data. Properties like
|
|
2086
|
+
`numberOfEmployees`, `foundingDate`, and `founder` are especially valuable for
|
|
2087
|
+
knowledge graph matching.
|
|
2088
|
+
|
|
2089
|
+
---
|
|
2090
|
+
|
|
2091
|
+
## Rich Person patterns
|
|
2092
|
+
|
|
2093
|
+
For personal sites, a detailed Person entity establishes identity and
|
|
2094
|
+
credibility. jonoalderson.com uses 80+ entities. Here's the extended pattern:
|
|
2095
|
+
|
|
2096
|
+
```ts
|
|
2097
|
+
buildPerson(
|
|
2098
|
+
{
|
|
2099
|
+
name: 'Jane Doe',
|
|
2100
|
+
familyName: 'Doe',
|
|
2101
|
+
birthDate: '1990-01-15',
|
|
2102
|
+
gender: 'female',
|
|
2103
|
+
nationality: { '@id': ids.country('US') },
|
|
2104
|
+
description: 'Software engineer and technical writer.',
|
|
2105
|
+
jobTitle: 'Lead Engineer',
|
|
2106
|
+
knowsLanguage: ['en', 'es', 'pt'],
|
|
2107
|
+
url: 'https://janedoe.com/about/',
|
|
2108
|
+
image: { '@id': ids.personImage },
|
|
2109
|
+
sameAs: [
|
|
2110
|
+
'https://twitter.com/janedoe',
|
|
2111
|
+
'https://github.com/janedoe',
|
|
2112
|
+
'https://linkedin.com/in/janedoe',
|
|
2113
|
+
'https://bsky.app/profile/janedoe.com',
|
|
2114
|
+
'https://mastodon.social/@janedoe',
|
|
2115
|
+
'https://en.wikipedia.org/wiki/Jane_Doe',
|
|
2116
|
+
],
|
|
2117
|
+
worksFor: [
|
|
2118
|
+
{
|
|
2119
|
+
'@type': 'EmployeeRole',
|
|
2120
|
+
roleName: 'Lead Engineer',
|
|
2121
|
+
startDate: '2022-01',
|
|
2122
|
+
worksFor: { '@id': ids.organization('acme') },
|
|
2123
|
+
},
|
|
2124
|
+
{
|
|
2125
|
+
'@type': 'EmployeeRole',
|
|
2126
|
+
roleName: 'Advisor',
|
|
2127
|
+
startDate: '2024-06',
|
|
2128
|
+
worksFor: { '@id': ids.organization('startup') },
|
|
2129
|
+
},
|
|
2130
|
+
],
|
|
2131
|
+
spouse: {
|
|
2132
|
+
'@type': 'Person',
|
|
2133
|
+
'@id': `${siteUrl}/#/schema.org/Person/john`,
|
|
2134
|
+
name: 'John Doe',
|
|
2135
|
+
},
|
|
2136
|
+
extra: {
|
|
2137
|
+
knowsAbout: [
|
|
2138
|
+
'TypeScript',
|
|
2139
|
+
'Schema.org',
|
|
2140
|
+
'Search Engine Optimization',
|
|
2141
|
+
'Web Performance',
|
|
2142
|
+
],
|
|
2143
|
+
honorificPrefix: 'Dr.',
|
|
2144
|
+
alumniOf: {
|
|
2145
|
+
'@type': 'EducationalOrganization',
|
|
2146
|
+
name: 'MIT',
|
|
2147
|
+
url: 'https://mit.edu/',
|
|
2148
|
+
},
|
|
2149
|
+
award: ['Best Developer Blog 2025', 'Open Source Contributor of the Year 2024'],
|
|
2150
|
+
},
|
|
2151
|
+
},
|
|
2152
|
+
ids,
|
|
2153
|
+
);
|
|
2154
|
+
```
|
|
2155
|
+
|
|
2156
|
+
**Practical advice:**
|
|
2157
|
+
|
|
2158
|
+
- `sameAs` is the most impactful property after name and url. It helps search
|
|
2159
|
+
engines connect your entity to external profiles and knowledge bases.
|
|
2160
|
+
- `worksFor` with `EmployeeRole` is better than plain Organization references
|
|
2161
|
+
because it captures role and tenure.
|
|
2162
|
+
- `knowsAbout` helps topical authority signals.
|
|
2163
|
+
- Include a Wikipedia `sameAs` link if one exists; it strongly anchors the
|
|
2164
|
+
entity in the knowledge graph.
|
|
2165
|
+
|
|
2166
|
+
---
|
|
2167
|
+
|
|
2168
|
+
## Reference implementations
|
|
2169
|
+
|
|
2170
|
+
Study these live sites for schema.org graph patterns. View any page's graph
|
|
2171
|
+
by searching the page source for `application/ld+json`.
|
|
2172
|
+
|
|
2173
|
+
### jonoalderson.com
|
|
2174
|
+
|
|
2175
|
+
An extensively structured personal site by Jono Alderson (former Head of SEO
|
|
2176
|
+
at Yoast, one of the foremost schema.org experts). Uses `BlogPosting` for
|
|
2177
|
+
articles and has one of the richest Person schemas on the web:
|
|
2178
|
+
|
|
2179
|
+
**Notable patterns:**
|
|
2180
|
+
|
|
2181
|
+
- 80+ entities on the homepage, 12+ on article pages
|
|
2182
|
+
- `BlogPosting` instead of `Article` for blog content
|
|
2183
|
+
- Person entity with `birthDate`, `birthPlace`, `nationality`, `award`,
|
|
2184
|
+
`spouse`, `pronouns`, employment history via `EmployeeRole`
|
|
2185
|
+
- Organization entities for every company in work history
|
|
2186
|
+
- Separate `Blog` entity as part of the WebSite
|
|
2187
|
+
- `SearchAction` with `EntryPoint` and `PropertyValueSpecification` on WebSite
|
|
2188
|
+
|
|
2189
|
+
### meta.com (product pages)
|
|
2190
|
+
|
|
2191
|
+
Meta's product pages (e.g. `/ai-glasses/ray-ban-meta-wayfarer-gen-2/`) are
|
|
2192
|
+
an excellent reference for e-commerce schema:
|
|
2193
|
+
|
|
2194
|
+
**Notable patterns:**
|
|
2195
|
+
|
|
2196
|
+
- `ProductGroup` with `hasVariant` array pointing to individual `Product` entities
|
|
2197
|
+
- 14 product variants, each with their own `sku`, `color`, `size`, and `Offer`
|
|
2198
|
+
- `MerchantReturnPolicy` with return window, method, and shipping cost
|
|
2199
|
+
- `OfferShippingDetails` with delivery cost and shipping destination
|
|
2200
|
+
- `BreadcrumbList` for product category navigation
|
|
2201
|
+
- `ItemPage` (WebPage subtype) for product detail pages
|
|
2202
|
+
- `Organization` with `legalName` ("Meta Platforms, Inc.") and social profiles
|
|
2203
|
+
- Dual brand references (Ray-Ban + Meta)
|
|
2204
|
+
|
|
2205
|
+
**Key takeaway for e-commerce:** Use `ProductGroup` when a product has variants
|
|
2206
|
+
(sizes, colors). Each variant gets its own `Product` entity with a unique `sku`.
|
|
2207
|
+
The `ProductGroup` ties them together.
|
|
2208
|
+
|
|
2209
|
+
### joost.blog
|
|
2210
|
+
|
|
2211
|
+
This library's own reference consumer. Astro site using `<Seo>` from
|
|
2212
|
+
`@jdevalk/astro-seo-graph`. Source code at
|
|
2213
|
+
[github.com/jdevalk/joost.blog](https://github.com/jdevalk/joost.blog).
|
|
2214
|
+
|
|
2215
|
+
**Notable patterns:**
|
|
2216
|
+
|
|
2217
|
+
- Schema endpoints at `/schema/post.json`, `/schema/video.json`,
|
|
2218
|
+
`/schema/page.json` with full article bodies (markdown-stripped, max 10K chars)
|
|
2219
|
+
- Schema map at `/schemamap.xml` for agent discovery
|
|
2220
|
+
- Person entity with 7 Organization references via `EmployeeRole`
|
|
2221
|
+
- Country entity for nationality
|
|
2222
|
+
- Family members (spouse, children) on homepage/about page
|
|
2223
|
+
- `ProfilePage` for about, `CollectionPage` for listings
|
|
2224
|
+
|
|
2225
|
+
### Validating against these references
|
|
2226
|
+
|
|
2227
|
+
When building a new site, compare your JSON-LD output against one of these
|
|
2228
|
+
references for the same page type. Use Google's Rich Results Test
|
|
2229
|
+
(https://search.google.com/test/rich-results) and check that:
|
|
2230
|
+
|
|
2231
|
+
1. Every `@id` reference resolves to an entity in the graph
|
|
2232
|
+
2. The entity relationship tree is complete (WebSite → WebPage → Article)
|
|
2233
|
+
3. The publisher chain is correct (Article.publisher and WebSite.publisher match)
|
|
2234
|
+
|
|
2235
|
+
---
|
|
2236
|
+
|
|
2237
|
+
## Astro integration guide
|
|
2238
|
+
|
|
2239
|
+
### The `<Seo>` component
|
|
2240
|
+
|
|
2241
|
+
Single component for all head metadata. Import from the Astro package:
|
|
2242
|
+
|
|
2243
|
+
```astro
|
|
2244
|
+
---
|
|
2245
|
+
import Seo from '@jdevalk/astro-seo-graph/Seo.astro';
|
|
2246
|
+
---
|
|
2247
|
+
|
|
2248
|
+
<html>
|
|
2249
|
+
<head>
|
|
2250
|
+
<Seo
|
|
2251
|
+
title="Page Title | Site Name"
|
|
2252
|
+
description="Page description for search engines."
|
|
2253
|
+
canonical="https://example.com/page/"
|
|
2254
|
+
ogType="article"
|
|
2255
|
+
ogImage="https://example.com/og/page.jpg"
|
|
2256
|
+
ogImageAlt="Description of the image"
|
|
2257
|
+
ogImageWidth={1200}
|
|
2258
|
+
ogImageHeight={675}
|
|
2259
|
+
siteName="Site Name"
|
|
2260
|
+
locale="en_US"
|
|
2261
|
+
twitter={{ card: 'summary_large_image', site: '@handle', creator: '@handle' }}
|
|
2262
|
+
article={{
|
|
2263
|
+
publishedTime: new Date('2026-01-15'),
|
|
2264
|
+
modifiedTime: new Date('2026-03-01'),
|
|
2265
|
+
authors: ['https://example.com/about/'],
|
|
2266
|
+
tags: ['TypeScript', 'SEO'],
|
|
2267
|
+
section: 'Technology',
|
|
2268
|
+
}}
|
|
2269
|
+
graph={graph}
|
|
2270
|
+
noindex={false}
|
|
2271
|
+
extraLinks={[
|
|
2272
|
+
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
|
|
2273
|
+
{ rel: 'sitemap', href: '/sitemap-index.xml' },
|
|
2274
|
+
{ rel: 'alternate', type: 'application/rss+xml', href: '/feed.xml', title: 'RSS' },
|
|
2275
|
+
]}
|
|
2276
|
+
extraMeta={[
|
|
2277
|
+
{ name: 'author', content: 'Jane Doe' },
|
|
2278
|
+
]}
|
|
2279
|
+
/>
|
|
2280
|
+
</head>
|
|
2281
|
+
<body>...</body>
|
|
2282
|
+
</html>
|
|
2283
|
+
```
|
|
2284
|
+
|
|
2285
|
+
### `<Seo>` props reference
|
|
2286
|
+
|
|
2287
|
+
| Prop | Type | Required | Default | Purpose |
|
|
2288
|
+
| --------------- | ------------------------------------------------------------------------------- | -------- | ---------------- | ------------------------------------ |
|
|
2289
|
+
| `title` | `string` | Yes | — | Full page title |
|
|
2290
|
+
| `titleTemplate` | `string` | No | — | Template with `%s` placeholder |
|
|
2291
|
+
| `description` | `string` | No | — | Meta description |
|
|
2292
|
+
| `canonical` | `string \| URL` | No | Current page URL | Canonical URL |
|
|
2293
|
+
| `ogType` | `'website' \| 'article' \| 'profile' \| 'book'` | No | `'website'` | Open Graph type |
|
|
2294
|
+
| `ogImage` | `string` | No | — | OG image (absolute URL) |
|
|
2295
|
+
| `ogImageAlt` | `string` | No | — | OG image alt text |
|
|
2296
|
+
| `ogImageWidth` | `number` | No | — | OG image width (px) |
|
|
2297
|
+
| `ogImageHeight` | `number` | No | — | OG image height (px) |
|
|
2298
|
+
| `siteName` | `string` | No | — | Site name for OG |
|
|
2299
|
+
| `locale` | `string` | No | `'en_US'` | OG locale |
|
|
2300
|
+
| `twitter` | `{ card?, site?, creator? }` | No | — | Twitter card settings |
|
|
2301
|
+
| `article` | `{ publishedTime?, modifiedTime?, expirationTime?, authors?, tags?, section? }` | No | — | Article OG metadata |
|
|
2302
|
+
| `noindex` | `boolean` | No | `false` | Emit `robots: noindex` |
|
|
2303
|
+
| `graph` | `object \| null` | No | — | JSON-LD graph from `assembleGraph()` |
|
|
2304
|
+
| `alternates` | `{ defaultLocale?, entries[] }` | No | — | hreflang alternate links |
|
|
2305
|
+
| `extraLinks` | `Array<Record<string, string>>` | No | — | Additional `<link>` elements |
|
|
2306
|
+
| `extraMeta` | `Array<Record<string, string>>` | No | — | Additional `<meta>` elements |
|
|
2307
|
+
|
|
2308
|
+
### hreflang alternates
|
|
2309
|
+
|
|
2310
|
+
For multilingual sites:
|
|
2311
|
+
|
|
2312
|
+
```astro
|
|
2313
|
+
<Seo
|
|
2314
|
+
title="Hello"
|
|
2315
|
+
alternates={{
|
|
2316
|
+
defaultLocale: 'en',
|
|
2317
|
+
entries: [
|
|
2318
|
+
{ hreflang: 'en', href: 'https://example.com/hello/' },
|
|
2319
|
+
{ hreflang: 'fr-CA', href: 'https://example.com/fr-ca/bonjour/' },
|
|
2320
|
+
{ hreflang: 'nl', href: 'https://example.com/nl/hallo/' },
|
|
2321
|
+
],
|
|
2322
|
+
}}
|
|
2323
|
+
/>
|
|
2324
|
+
```
|
|
2325
|
+
|
|
2326
|
+
**Rules:**
|
|
2327
|
+
|
|
2328
|
+
- Absolute URLs only. Relative, protocol-relative, and non-http schemes are dropped.
|
|
2329
|
+
- Include the current page (self-referential hreflang is required by Google).
|
|
2330
|
+
- BCP 47 tags are auto-normalized (fr-ca becomes fr-CA).
|
|
2331
|
+
- `x-default` is added automatically, pointing at `defaultLocale`.
|
|
2332
|
+
- If fewer than 2 entries survive validation, nothing is emitted.
|
|
2333
|
+
|
|
2334
|
+
### Schema endpoints
|
|
2335
|
+
|
|
2336
|
+
Expose a corpus-wide JSON-LD graph as an API endpoint:
|
|
2337
|
+
|
|
2338
|
+
```ts
|
|
2339
|
+
// src/pages/schema/post.json.ts
|
|
2340
|
+
import { createSchemaEndpoint } from '@jdevalk/astro-seo-graph';
|
|
2341
|
+
import { getCollection } from 'astro:content';
|
|
2342
|
+
import { makeIds, buildWebPage, buildArticle } from '@jdevalk/seo-graph-core';
|
|
2343
|
+
|
|
2344
|
+
const ids = makeIds({ siteUrl: 'https://example.com' });
|
|
2345
|
+
|
|
2346
|
+
export const GET = createSchemaEndpoint({
|
|
2347
|
+
entries: () => getCollection('blog'),
|
|
2348
|
+
mapper: (post) => {
|
|
2349
|
+
const url = `https://example.com/${post.id}/`;
|
|
2350
|
+
return [
|
|
2351
|
+
buildWebPage(
|
|
2352
|
+
{
|
|
2353
|
+
url,
|
|
2354
|
+
name: post.data.title,
|
|
2355
|
+
isPartOf: { '@id': ids.website },
|
|
2356
|
+
breadcrumb: { '@id': ids.breadcrumb(url) },
|
|
2357
|
+
datePublished: post.data.publishDate,
|
|
2358
|
+
},
|
|
2359
|
+
ids,
|
|
2360
|
+
),
|
|
2361
|
+
buildArticle(
|
|
2362
|
+
{
|
|
2363
|
+
url,
|
|
2364
|
+
isPartOf: { '@id': ids.webPage(url) },
|
|
2365
|
+
author: { '@id': ids.person },
|
|
2366
|
+
publisher: { '@id': ids.person },
|
|
2367
|
+
headline: post.data.title,
|
|
2368
|
+
description: post.data.excerpt ?? '',
|
|
2369
|
+
datePublished: post.data.publishDate,
|
|
2370
|
+
},
|
|
2371
|
+
ids,
|
|
2372
|
+
),
|
|
2373
|
+
];
|
|
2374
|
+
},
|
|
2375
|
+
cacheControl: 'max-age=300', // optional, defaults to 5 minutes
|
|
2376
|
+
indent: 2, // optional, defaults to 2
|
|
2377
|
+
});
|
|
2378
|
+
```
|
|
2379
|
+
|
|
2380
|
+
**Options:**
|
|
2381
|
+
| Option | Type | Default | Purpose |
|
|
2382
|
+
|---|---|---|---|
|
|
2383
|
+
| `entries` | `() => Promise<Entry[]>` | — | Async content source |
|
|
2384
|
+
| `mapper` | `(entry: Entry) => GraphEntity[]` | — | Convert entry to schema pieces |
|
|
2385
|
+
| `cacheControl` | `string \| null` | `'max-age=300'` | Cache-Control header. `null` to omit. |
|
|
2386
|
+
| `contentType` | `string` | `'application/ld+json'` | Response content type |
|
|
2387
|
+
| `indent` | `number` | `2` | JSON indentation. `0` for compact. |
|
|
2388
|
+
|
|
2389
|
+
### Schema map discovery
|
|
2390
|
+
|
|
2391
|
+
Provide a sitemap-style XML document listing your schema endpoints:
|
|
2392
|
+
|
|
2393
|
+
```ts
|
|
2394
|
+
// src/pages/schemamap.xml.ts
|
|
2395
|
+
import { createSchemaMap } from '@jdevalk/astro-seo-graph';
|
|
2396
|
+
|
|
2397
|
+
export const GET = createSchemaMap({
|
|
2398
|
+
siteUrl: 'https://example.com',
|
|
2399
|
+
entries: [
|
|
2400
|
+
{ path: '/schema/post.json', lastModified: new Date('2026-04-10') },
|
|
2401
|
+
{ path: '/schema/video.json', lastModified: new Date('2026-03-15') },
|
|
2402
|
+
{ path: '/schema/page.json', lastModified: new Date('2026-02-01') },
|
|
2403
|
+
],
|
|
2404
|
+
});
|
|
2405
|
+
```
|
|
2406
|
+
|
|
2407
|
+
**Schema map entry options:**
|
|
2408
|
+
| Field | Type | Default | Purpose |
|
|
2409
|
+
|---|---|---|---|
|
|
2410
|
+
| `path` | `string` | — | Relative path to schema endpoint |
|
|
2411
|
+
| `lastModified` | `Date` | — | Last modification date |
|
|
2412
|
+
| `changeFreq` | Sitemap frequency string | `'daily'` | Update frequency hint |
|
|
2413
|
+
| `priority` | `number` | `0.8` | Priority hint (0.0-1.0) |
|
|
2414
|
+
|
|
2415
|
+
### The `aggregate` function
|
|
2416
|
+
|
|
2417
|
+
The engine behind `createSchemaEndpoint`. Use directly when you need custom
|
|
2418
|
+
assembly, caching, or multi-collection merging:
|
|
2419
|
+
|
|
2420
|
+
```ts
|
|
2421
|
+
import { aggregate } from '@jdevalk/astro-seo-graph';
|
|
2422
|
+
|
|
2423
|
+
const result = aggregate({
|
|
2424
|
+
entries: await getCollection('blog'),
|
|
2425
|
+
mapper: (post) => [...buildPiecesForPost(post)],
|
|
2426
|
+
});
|
|
2427
|
+
// result = { '@context': 'https://schema.org', '@graph': [...] }
|
|
2428
|
+
```
|
|
2429
|
+
|
|
2430
|
+
### Zod content helpers
|
|
2431
|
+
|
|
2432
|
+
Use in `src/content.config.ts` to validate SEO fields on content collections:
|
|
2433
|
+
|
|
2434
|
+
```ts
|
|
2435
|
+
import { defineCollection, z } from 'astro:content';
|
|
2436
|
+
import { seoSchema, imageSchema } from '@jdevalk/astro-seo-graph';
|
|
2437
|
+
|
|
2438
|
+
const blog = defineCollection({
|
|
2439
|
+
schema: ({ image }) =>
|
|
2440
|
+
z.object({
|
|
2441
|
+
title: z.string(),
|
|
2442
|
+
publishDate: z.coerce.date(),
|
|
2443
|
+
excerpt: z.string().optional(),
|
|
2444
|
+
featureImage: imageSchema(image).optional(),
|
|
2445
|
+
seo: seoSchema(image).optional(),
|
|
2446
|
+
}),
|
|
2447
|
+
});
|
|
2448
|
+
```
|
|
2449
|
+
|
|
2450
|
+
**`seoSchema(image)` shape:**
|
|
2451
|
+
|
|
2452
|
+
```ts
|
|
2453
|
+
{
|
|
2454
|
+
title: z.string().min(5).max(120).optional(),
|
|
2455
|
+
description: z.string().min(15).max(160).optional(),
|
|
2456
|
+
image: imageSchema(image).optional(),
|
|
2457
|
+
pageType: z.enum(['website', 'article']).default('website'),
|
|
2458
|
+
}
|
|
2459
|
+
```
|
|
2460
|
+
|
|
2461
|
+
**`imageSchema(image)` shape:**
|
|
2462
|
+
|
|
2463
|
+
```ts
|
|
2464
|
+
{
|
|
2465
|
+
src: image(),
|
|
2466
|
+
alt: z.string().optional(),
|
|
2467
|
+
}
|
|
2468
|
+
```
|
|
2469
|
+
|
|
2470
|
+
### `buildAstroSeoProps`
|
|
2471
|
+
|
|
2472
|
+
Pure-TS function that powers `<Seo>` internally. Use when you want to feed a
|
|
2473
|
+
different head component:
|
|
2474
|
+
|
|
2475
|
+
```ts
|
|
2476
|
+
import { buildAstroSeoProps } from '@jdevalk/astro-seo-graph';
|
|
2477
|
+
|
|
2478
|
+
const astroSeoProps = buildAstroSeoProps(mySeoProps, Astro.url.href);
|
|
2479
|
+
// Pass to astro-seo's <SEO> or any custom head component
|
|
2480
|
+
```
|
|
2481
|
+
|
|
2482
|
+
### `buildAlternateLinks`
|
|
2483
|
+
|
|
2484
|
+
Pure helper for hreflang link generation. No Astro runtime needed — safe
|
|
2485
|
+
to use from non-Astro contexts:
|
|
2486
|
+
|
|
2487
|
+
```ts
|
|
2488
|
+
import { buildAlternateLinks } from '@jdevalk/astro-seo-graph';
|
|
2489
|
+
|
|
2490
|
+
const links = buildAlternateLinks({
|
|
2491
|
+
defaultLocale: 'en',
|
|
2492
|
+
entries: [
|
|
2493
|
+
{ hreflang: 'en', href: 'https://example.com/hello/' },
|
|
2494
|
+
{ hreflang: 'fr', href: 'https://example.com/fr/bonjour/' },
|
|
2495
|
+
],
|
|
2496
|
+
});
|
|
2497
|
+
// → [{ rel: 'alternate', hreflang: 'en', href: '...' }, ..., { hreflang: 'x-default', ... }]
|
|
2498
|
+
```
|
|
2499
|
+
|
|
2500
|
+
---
|
|
2501
|
+
|
|
2502
|
+
## Complete integration example
|
|
2503
|
+
|
|
2504
|
+
Here's how joost.blog wires everything together. Use this as a reference
|
|
2505
|
+
for a full personal blog setup.
|
|
2506
|
+
|
|
2507
|
+
### 1. Content config (`src/content.config.ts`)
|
|
2508
|
+
|
|
2509
|
+
```ts
|
|
2510
|
+
import { defineCollection, z } from 'astro:content';
|
|
2511
|
+
import { seoSchema, imageSchema } from '@jdevalk/astro-seo-graph';
|
|
2512
|
+
|
|
2513
|
+
const blog = defineCollection({
|
|
2514
|
+
schema: ({ image }) =>
|
|
2515
|
+
z.object({
|
|
2516
|
+
title: z.string(),
|
|
2517
|
+
excerpt: z.string().optional(),
|
|
2518
|
+
publishDate: z.coerce.date(),
|
|
2519
|
+
updatedDate: z.coerce.date().optional(),
|
|
2520
|
+
featureImage: imageSchema(image).optional(),
|
|
2521
|
+
featureImageAlt: z.string().optional(),
|
|
2522
|
+
categories: z.array(z.string()).optional(),
|
|
2523
|
+
draft: z.boolean().default(false),
|
|
2524
|
+
seo: seoSchema(image).optional(),
|
|
2525
|
+
}),
|
|
2526
|
+
});
|
|
2527
|
+
```
|
|
2528
|
+
|
|
2529
|
+
### 2. Schema utility (`src/utils/schema/index.ts`)
|
|
2530
|
+
|
|
2531
|
+
```ts
|
|
2532
|
+
import {
|
|
2533
|
+
makeIds,
|
|
2534
|
+
assembleGraph,
|
|
2535
|
+
buildWebSite,
|
|
2536
|
+
buildPerson,
|
|
2537
|
+
buildWebPage,
|
|
2538
|
+
buildArticle,
|
|
2539
|
+
buildBreadcrumbList,
|
|
2540
|
+
buildImageObject,
|
|
2541
|
+
buildSiteNavigationElement,
|
|
2542
|
+
} from '@jdevalk/seo-graph-core';
|
|
2543
|
+
|
|
2544
|
+
const SITE_URL = 'https://example.com';
|
|
2545
|
+
export const ids = makeIds({ siteUrl: SITE_URL, personUrl: `${SITE_URL}/about/` });
|
|
2546
|
+
|
|
2547
|
+
// Site-wide entities — included on every page
|
|
2548
|
+
function siteWideEntities() {
|
|
2549
|
+
return [
|
|
2550
|
+
buildWebSite(
|
|
2551
|
+
{ url: `${SITE_URL}/`, name: 'My Blog', publisher: { '@id': ids.person } },
|
|
2552
|
+
ids,
|
|
2553
|
+
),
|
|
2554
|
+
buildPerson(
|
|
2555
|
+
{
|
|
2556
|
+
name: 'Jane Doe',
|
|
2557
|
+
url: `${SITE_URL}/about/`,
|
|
2558
|
+
image: { '@id': ids.personImage },
|
|
2559
|
+
sameAs: ['...'],
|
|
2560
|
+
},
|
|
2561
|
+
ids,
|
|
2562
|
+
),
|
|
2563
|
+
buildImageObject(
|
|
2564
|
+
{ id: ids.personImage, url: `${SITE_URL}/jane.jpg`, width: 400, height: 400 },
|
|
2565
|
+
ids,
|
|
2566
|
+
),
|
|
2567
|
+
buildSiteNavigationElement(
|
|
2568
|
+
{
|
|
2569
|
+
name: 'Main navigation',
|
|
2570
|
+
isPartOf: { '@id': ids.website },
|
|
2571
|
+
items: [
|
|
2572
|
+
{ name: 'Home', url: `${SITE_URL}/` },
|
|
2573
|
+
{ name: 'Blog', url: `${SITE_URL}/blog/` },
|
|
2574
|
+
{ name: 'About', url: `${SITE_URL}/about/` },
|
|
2575
|
+
],
|
|
2576
|
+
},
|
|
2577
|
+
ids,
|
|
2578
|
+
),
|
|
2579
|
+
];
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
// Page-specific graph builder
|
|
2583
|
+
export function buildSchemaGraph(opts: {
|
|
2584
|
+
pageType: string;
|
|
2585
|
+
url: string;
|
|
2586
|
+
title: string;
|
|
2587
|
+
description: string;
|
|
2588
|
+
publishDate?: Date;
|
|
2589
|
+
updatedDate?: Date;
|
|
2590
|
+
featureImageUrl?: string;
|
|
2591
|
+
category?: string;
|
|
2592
|
+
}) {
|
|
2593
|
+
const pieces = [...siteWideEntities()];
|
|
2594
|
+
const { url, title, description, publishDate, updatedDate, featureImageUrl, category } = opts;
|
|
2595
|
+
|
|
2596
|
+
switch (opts.pageType) {
|
|
2597
|
+
case 'blogPost':
|
|
2598
|
+
pieces.push(
|
|
2599
|
+
buildWebPage(
|
|
2600
|
+
{
|
|
2601
|
+
url,
|
|
2602
|
+
name: title,
|
|
2603
|
+
isPartOf: { '@id': ids.website },
|
|
2604
|
+
breadcrumb: { '@id': ids.breadcrumb(url) },
|
|
2605
|
+
datePublished: publishDate,
|
|
2606
|
+
dateModified: updatedDate,
|
|
2607
|
+
primaryImage: featureImageUrl
|
|
2608
|
+
? { '@id': ids.primaryImage(url) }
|
|
2609
|
+
: undefined,
|
|
2610
|
+
},
|
|
2611
|
+
ids,
|
|
2612
|
+
),
|
|
2613
|
+
buildArticle(
|
|
2614
|
+
{
|
|
2615
|
+
url,
|
|
2616
|
+
isPartOf: { '@id': ids.webPage(url) },
|
|
2617
|
+
author: { '@id': ids.person },
|
|
2618
|
+
publisher: { '@id': ids.person },
|
|
2619
|
+
headline: title,
|
|
2620
|
+
description,
|
|
2621
|
+
datePublished: publishDate!,
|
|
2622
|
+
dateModified: updatedDate,
|
|
2623
|
+
image: featureImageUrl ? { '@id': ids.primaryImage(url) } : undefined,
|
|
2624
|
+
articleSection: category,
|
|
2625
|
+
},
|
|
2626
|
+
ids,
|
|
2627
|
+
),
|
|
2628
|
+
buildBreadcrumbList(
|
|
2629
|
+
{
|
|
2630
|
+
url,
|
|
2631
|
+
items: [
|
|
2632
|
+
{ name: 'Home', url: `${SITE_URL}/` },
|
|
2633
|
+
{ name: 'Blog', url: `${SITE_URL}/blog/` },
|
|
2634
|
+
{ name: title, url },
|
|
2635
|
+
],
|
|
2636
|
+
},
|
|
2637
|
+
ids,
|
|
2638
|
+
),
|
|
2639
|
+
);
|
|
2640
|
+
if (featureImageUrl) {
|
|
2641
|
+
pieces.push(
|
|
2642
|
+
buildImageObject(
|
|
2643
|
+
{ pageUrl: url, url: featureImageUrl, width: 1200, height: 630 },
|
|
2644
|
+
ids,
|
|
2645
|
+
),
|
|
2646
|
+
);
|
|
2647
|
+
}
|
|
2648
|
+
break;
|
|
2649
|
+
case 'blogListing':
|
|
2650
|
+
pieces.push(
|
|
2651
|
+
buildWebPage(
|
|
2652
|
+
{ url, name: title, isPartOf: { '@id': ids.website } },
|
|
2653
|
+
ids,
|
|
2654
|
+
'CollectionPage',
|
|
2655
|
+
),
|
|
2656
|
+
);
|
|
2657
|
+
break;
|
|
2658
|
+
case 'about':
|
|
2659
|
+
pieces.push(
|
|
2660
|
+
buildWebPage(
|
|
2661
|
+
{
|
|
2662
|
+
url,
|
|
2663
|
+
name: title,
|
|
2664
|
+
isPartOf: { '@id': ids.website },
|
|
2665
|
+
about: { '@id': ids.person },
|
|
2666
|
+
},
|
|
2667
|
+
ids,
|
|
2668
|
+
'ProfilePage',
|
|
2669
|
+
),
|
|
2670
|
+
);
|
|
2671
|
+
break;
|
|
2672
|
+
default:
|
|
2673
|
+
pieces.push(buildWebPage({ url, name: title, isPartOf: { '@id': ids.website } }, ids));
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
return assembleGraph(pieces);
|
|
2677
|
+
}
|
|
2678
|
+
```
|
|
2679
|
+
|
|
2680
|
+
### 3. Head component (`src/components/BaseHead.astro`)
|
|
2681
|
+
|
|
2682
|
+
```astro
|
|
2683
|
+
---
|
|
2684
|
+
import Seo from '@jdevalk/astro-seo-graph/Seo.astro';
|
|
2685
|
+
import { buildSchemaGraph } from '../utils/schema';
|
|
2686
|
+
|
|
2687
|
+
const { title, description, pageType = 'page', publishDate, categories } = Astro.props;
|
|
2688
|
+
|
|
2689
|
+
const graph = buildSchemaGraph({
|
|
2690
|
+
pageType,
|
|
2691
|
+
url: Astro.url.href,
|
|
2692
|
+
title,
|
|
2693
|
+
description,
|
|
2694
|
+
publishDate,
|
|
2695
|
+
category: categories?.[0],
|
|
2696
|
+
});
|
|
2697
|
+
|
|
2698
|
+
const ogImage = new URL(`/og/${Astro.url.pathname.replace(/\//g, '') || 'index'}.jpg`, Astro.site).toString();
|
|
2699
|
+
---
|
|
2700
|
+
|
|
2701
|
+
<Seo
|
|
2702
|
+
title={`${title} | My Blog`}
|
|
2703
|
+
description={description}
|
|
2704
|
+
canonical={Astro.url.href}
|
|
2705
|
+
ogType={pageType === 'blogPost' ? 'article' : 'website'}
|
|
2706
|
+
ogImage={ogImage}
|
|
2707
|
+
ogImageAlt={title}
|
|
2708
|
+
ogImageWidth={1200}
|
|
2709
|
+
ogImageHeight={675}
|
|
2710
|
+
siteName="My Blog"
|
|
2711
|
+
twitter={{ card: 'summary_large_image', site: '@handle' }}
|
|
2712
|
+
article={pageType === 'blogPost' && publishDate ? { publishedTime: publishDate, tags: categories } : undefined}
|
|
2713
|
+
graph={graph}
|
|
2714
|
+
extraLinks={[
|
|
2715
|
+
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
|
|
2716
|
+
{ rel: 'sitemap', href: '/sitemap-index.xml' },
|
|
2717
|
+
]}
|
|
2718
|
+
/>
|
|
2719
|
+
```
|
|
2720
|
+
|
|
2721
|
+
### 4. Schema endpoint (`src/pages/schema/post.json.ts`)
|
|
2722
|
+
|
|
2723
|
+
```ts
|
|
2724
|
+
import { createSchemaEndpoint } from '@jdevalk/astro-seo-graph';
|
|
2725
|
+
import { getCollection } from 'astro:content';
|
|
2726
|
+
import { ids } from '../../utils/schema';
|
|
2727
|
+
import { buildWebPage, buildArticle, buildBreadcrumbList } from '@jdevalk/seo-graph-core';
|
|
2728
|
+
|
|
2729
|
+
const SITE_URL = 'https://example.com';
|
|
2730
|
+
|
|
2731
|
+
export const GET = createSchemaEndpoint({
|
|
2732
|
+
entries: async () => {
|
|
2733
|
+
const posts = await getCollection('blog');
|
|
2734
|
+
return posts.filter((p) => !p.data.draft);
|
|
2735
|
+
},
|
|
2736
|
+
mapper: (post) => {
|
|
2737
|
+
const url = `${SITE_URL}/${post.id}/`;
|
|
2738
|
+
return [
|
|
2739
|
+
buildWebPage(
|
|
2740
|
+
{
|
|
2741
|
+
url,
|
|
2742
|
+
name: post.data.title,
|
|
2743
|
+
isPartOf: { '@id': ids.website },
|
|
2744
|
+
breadcrumb: { '@id': ids.breadcrumb(url) },
|
|
2745
|
+
datePublished: post.data.publishDate,
|
|
2746
|
+
},
|
|
2747
|
+
ids,
|
|
2748
|
+
),
|
|
2749
|
+
buildArticle(
|
|
2750
|
+
{
|
|
2751
|
+
url,
|
|
2752
|
+
isPartOf: { '@id': ids.webPage(url) },
|
|
2753
|
+
author: { '@id': ids.person },
|
|
2754
|
+
publisher: { '@id': ids.person },
|
|
2755
|
+
headline: post.data.title,
|
|
2756
|
+
description: post.data.excerpt ?? '',
|
|
2757
|
+
datePublished: post.data.publishDate,
|
|
2758
|
+
dateModified: post.data.updatedDate,
|
|
2759
|
+
},
|
|
2760
|
+
ids,
|
|
2761
|
+
),
|
|
2762
|
+
buildBreadcrumbList(
|
|
2763
|
+
{
|
|
2764
|
+
url,
|
|
2765
|
+
items: [
|
|
2766
|
+
{ name: 'Home', url: `${SITE_URL}/` },
|
|
2767
|
+
{ name: 'Blog', url: `${SITE_URL}/blog/` },
|
|
2768
|
+
{ name: post.data.title, url },
|
|
2769
|
+
],
|
|
2770
|
+
},
|
|
2771
|
+
ids,
|
|
2772
|
+
),
|
|
2773
|
+
];
|
|
2774
|
+
},
|
|
2775
|
+
});
|
|
2776
|
+
```
|
|
2777
|
+
|
|
2778
|
+
### 5. Schema map (`src/pages/schemamap.xml.ts`)
|
|
2779
|
+
|
|
2780
|
+
```ts
|
|
2781
|
+
import { createSchemaMap } from '@jdevalk/astro-seo-graph';
|
|
2782
|
+
|
|
2783
|
+
export const GET = createSchemaMap({
|
|
2784
|
+
siteUrl: 'https://example.com',
|
|
2785
|
+
entries: [{ path: '/schema/post.json', lastModified: new Date() }],
|
|
2786
|
+
});
|
|
2787
|
+
```
|
|
2788
|
+
|
|
2789
|
+
---
|
|
2790
|
+
|
|
2791
|
+
## Advanced patterns
|
|
2792
|
+
|
|
2793
|
+
### Multiple organizations
|
|
2794
|
+
|
|
2795
|
+
When a person works for several companies, create an organization for each:
|
|
2796
|
+
|
|
2797
|
+
```ts
|
|
2798
|
+
const orgs = [
|
|
2799
|
+
{ slug: 'acme', name: 'Acme Corp', url: 'https://acme.com/' },
|
|
2800
|
+
{ slug: 'side-project', name: 'Side Project Inc', url: 'https://sideproject.com/' },
|
|
2801
|
+
];
|
|
2802
|
+
|
|
2803
|
+
const orgPieces = orgs.map((org) => buildOrganization(org, ids));
|
|
2804
|
+
|
|
2805
|
+
const personPiece = buildPerson(
|
|
2806
|
+
{
|
|
2807
|
+
name: 'Jane Doe',
|
|
2808
|
+
worksFor: [
|
|
2809
|
+
{
|
|
2810
|
+
'@type': 'EmployeeRole',
|
|
2811
|
+
roleName: 'CEO',
|
|
2812
|
+
startDate: '2020',
|
|
2813
|
+
worksFor: { '@id': ids.organization('acme') },
|
|
2814
|
+
},
|
|
2815
|
+
{
|
|
2816
|
+
'@type': 'EmployeeRole',
|
|
2817
|
+
roleName: 'Advisor',
|
|
2818
|
+
startDate: '2023',
|
|
2819
|
+
worksFor: { '@id': ids.organization('side-project') },
|
|
2820
|
+
},
|
|
2821
|
+
],
|
|
2822
|
+
},
|
|
2823
|
+
ids,
|
|
2824
|
+
);
|
|
2825
|
+
```
|
|
2826
|
+
|
|
2827
|
+
### Organization subtypes
|
|
2828
|
+
|
|
2829
|
+
Use `schema-dts` generics for full type safety on subtypes:
|
|
2830
|
+
|
|
2831
|
+
```ts
|
|
2832
|
+
import type { Dentist, Hotel, EducationalOrganization } from 'schema-dts';
|
|
2833
|
+
|
|
2834
|
+
buildOrganization<Dentist>(
|
|
2835
|
+
{ slug: 'clinic', name: 'Smile Dental', extra: { medicalSpecialty: 'Dentistry' } },
|
|
2836
|
+
ids,
|
|
2837
|
+
'Dentist',
|
|
2838
|
+
);
|
|
2839
|
+
buildOrganization<Hotel>(
|
|
2840
|
+
{
|
|
2841
|
+
slug: 'hotel',
|
|
2842
|
+
name: 'Grand Hotel',
|
|
2843
|
+
extra: {
|
|
2844
|
+
starRating: { '@type': 'Rating', ratingValue: 4 },
|
|
2845
|
+
checkinTime: '15:00',
|
|
2846
|
+
checkoutTime: '11:00',
|
|
2847
|
+
},
|
|
2848
|
+
},
|
|
2849
|
+
ids,
|
|
2850
|
+
'Hotel',
|
|
2851
|
+
);
|
|
2852
|
+
```
|
|
2853
|
+
|
|
2854
|
+
### Multi-author blogs
|
|
2855
|
+
|
|
2856
|
+
When different posts have different authors, use `buildCustomPiece` for author
|
|
2857
|
+
Person entities (since `buildPerson` always uses `ids.person` as the `@id`):
|
|
2858
|
+
|
|
2859
|
+
```ts
|
|
2860
|
+
const authorId = `${siteUrl}/authors/${authorSlug}/#person`;
|
|
2861
|
+
const authorPiece = buildCustomPiece({
|
|
2862
|
+
'@type': 'Person',
|
|
2863
|
+
'@id': authorId,
|
|
2864
|
+
name: authorName,
|
|
2865
|
+
url: `${siteUrl}/authors/${authorSlug}/`,
|
|
2866
|
+
image: authorAvatarUrl,
|
|
2867
|
+
});
|
|
2868
|
+
const articlePiece = buildArticle(
|
|
2869
|
+
{
|
|
2870
|
+
url,
|
|
2871
|
+
isPartOf: { '@id': ids.webPage(url) },
|
|
2872
|
+
author: { '@id': authorId },
|
|
2873
|
+
publisher: { '@id': ids.organization('company') },
|
|
2874
|
+
headline: title,
|
|
2875
|
+
description,
|
|
2876
|
+
datePublished,
|
|
2877
|
+
},
|
|
2878
|
+
ids,
|
|
2879
|
+
);
|
|
2880
|
+
```
|
|
2881
|
+
|
|
2882
|
+
### Non-Astro usage (Next.js, SvelteKit, etc.)
|
|
2883
|
+
|
|
2884
|
+
Use `@jdevalk/seo-graph-core` directly. Build your graph, then inject it as a
|
|
2885
|
+
`<script type="application/ld+json">` tag:
|
|
2886
|
+
|
|
2887
|
+
```tsx
|
|
2888
|
+
// Next.js example
|
|
2889
|
+
import {
|
|
2890
|
+
makeIds,
|
|
2891
|
+
assembleGraph,
|
|
2892
|
+
buildWebSite,
|
|
2893
|
+
buildWebPage,
|
|
2894
|
+
buildArticle,
|
|
2895
|
+
} from '@jdevalk/seo-graph-core';
|
|
2896
|
+
|
|
2897
|
+
const ids = makeIds({ siteUrl: 'https://example.com' });
|
|
2898
|
+
|
|
2899
|
+
export default function BlogPost({ post }) {
|
|
2900
|
+
const url = `https://example.com/blog/${post.slug}`;
|
|
2901
|
+
const graph = assembleGraph([
|
|
2902
|
+
buildWebSite(
|
|
2903
|
+
{ url: 'https://example.com/', name: 'My Site', publisher: { '@id': ids.person } },
|
|
2904
|
+
ids,
|
|
2905
|
+
),
|
|
2906
|
+
buildWebPage(
|
|
2907
|
+
{
|
|
2908
|
+
url,
|
|
2909
|
+
name: post.title,
|
|
2910
|
+
isPartOf: { '@id': ids.website },
|
|
2911
|
+
datePublished: new Date(post.date),
|
|
2912
|
+
},
|
|
2913
|
+
ids,
|
|
2914
|
+
),
|
|
2915
|
+
buildArticle(
|
|
2916
|
+
{
|
|
2917
|
+
url,
|
|
2918
|
+
isPartOf: { '@id': ids.webPage(url) },
|
|
2919
|
+
author: { '@id': ids.person },
|
|
2920
|
+
publisher: { '@id': ids.person },
|
|
2921
|
+
headline: post.title,
|
|
2922
|
+
description: post.excerpt,
|
|
2923
|
+
datePublished: new Date(post.date),
|
|
2924
|
+
},
|
|
2925
|
+
ids,
|
|
2926
|
+
),
|
|
2927
|
+
]);
|
|
2928
|
+
|
|
2929
|
+
return (
|
|
2930
|
+
<>
|
|
2931
|
+
<Head>
|
|
2932
|
+
<title>{post.title} | My Site</title>
|
|
2933
|
+
<script
|
|
2934
|
+
type="application/ld+json"
|
|
2935
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(graph) }}
|
|
2936
|
+
/>
|
|
2937
|
+
</Head>
|
|
2938
|
+
<article>{post.content}</article>
|
|
2939
|
+
</>
|
|
2940
|
+
);
|
|
2941
|
+
}
|
|
2942
|
+
```
|
|
2943
|
+
|
|
2944
|
+
---
|
|
2945
|
+
|
|
2946
|
+
## Common mistakes
|
|
2947
|
+
|
|
2948
|
+
1. **Forgetting to link entities.** Every `Article` needs `isPartOf` pointing to
|
|
2949
|
+
its `WebPage`. Every `WebPage` needs `isPartOf` pointing to the `WebSite`.
|
|
2950
|
+
Missing links produce valid JSON-LD but an unconnected graph that search
|
|
2951
|
+
engines can't walk.
|
|
2952
|
+
|
|
2953
|
+
2. **Duplicating site-wide entities.** `WebSite` and `Person` should appear once
|
|
2954
|
+
in the graph. `assembleGraph` deduplicates by `@id` (first wins), so it's
|
|
2955
|
+
safe to include them in every page's piece array.
|
|
2956
|
+
|
|
2957
|
+
3. **Using wrong WebPage subtype.** Archive/listing pages should be
|
|
2958
|
+
`CollectionPage`, not `WebPage`. About pages should be `ProfilePage`.
|
|
2959
|
+
|
|
2960
|
+
4. **Relative URLs.** All URLs in the graph must be absolute
|
|
2961
|
+
(`https://example.com/page/`, not `/page/`).
|
|
2962
|
+
|
|
2963
|
+
5. **Missing trailing slashes.** Be consistent. If your site uses trailing
|
|
2964
|
+
slashes, use them everywhere in the graph. Mismatched URLs create
|
|
2965
|
+
duplicate entities.
|
|
2966
|
+
|
|
2967
|
+
6. **Inlining entities instead of referencing.** Don't put a full Person object
|
|
2968
|
+
inside an Article's `author` field. Use `{ '@id': ids.person }` and let the
|
|
2969
|
+
graph resolver connect them.
|
|
2970
|
+
|
|
2971
|
+
7. **Not including the graph in the page head.** Building the graph is step one.
|
|
2972
|
+
You still need to render it as `<script type="application/ld+json">` in
|
|
2973
|
+
your page. The `<Seo>` component handles this via the `graph` prop. In
|
|
2974
|
+
non-Astro setups, inject it manually.
|
|
2975
|
+
|
|
2976
|
+
8. **Omitting `@context`.** Always use `assembleGraph()` to wrap your pieces.
|
|
2977
|
+
It adds `"@context": "https://schema.org"` automatically. Don't build the
|
|
2978
|
+
envelope by hand.
|
|
2979
|
+
|
|
2980
|
+
---
|
|
2981
|
+
|
|
2982
|
+
## Validating your output
|
|
2983
|
+
|
|
2984
|
+
After building a graph, validate it:
|
|
2985
|
+
|
|
2986
|
+
1. **Google Rich Results Test:** https://search.google.com/test/rich-results
|
|
2987
|
+
2. **Schema.org Validator:** https://validator.schema.org/
|
|
2988
|
+
3. **Check `@id` resolution:** Every `{ "@id": "..." }` reference in the graph
|
|
2989
|
+
should have a matching entity with that `@id`. If not, the reference is
|
|
2990
|
+
broken.
|
|
2991
|
+
|
|
2992
|
+
---
|
|
2993
|
+
|
|
2994
|
+
## Repository structure
|
|
2995
|
+
|
|
2996
|
+
```
|
|
2997
|
+
seo-graph/
|
|
2998
|
+
├── packages/
|
|
2999
|
+
│ ├── seo-graph-core/ # @jdevalk/seo-graph-core
|
|
3000
|
+
│ │ └── src/
|
|
3001
|
+
│ │ ├── index.ts # All exports
|
|
3002
|
+
│ │ ├── ids.ts # makeIds, IdFactory
|
|
3003
|
+
│ │ ├── assemble.ts # assembleGraph, deduplicateByGraphId
|
|
3004
|
+
│ │ ├── builders/ # One file per piece builder
|
|
3005
|
+
│ │ └── types.ts # GraphEntity, Reference, SchemaGraph
|
|
3006
|
+
│ └── astro-seo-graph/ # @jdevalk/astro-seo-graph
|
|
3007
|
+
│ └── src/
|
|
3008
|
+
│ ├── index.ts # All exports
|
|
3009
|
+
│ ├── Seo.astro # <Seo> component
|
|
3010
|
+
│ ├── routes.ts # createSchemaEndpoint, createSchemaMap
|
|
3011
|
+
│ ├── aggregator.ts # aggregate
|
|
3012
|
+
│ ├── alternates.ts # buildAlternateLinks
|
|
3013
|
+
│ ├── content.ts # seoSchema, imageSchema
|
|
3014
|
+
│ └── components/
|
|
3015
|
+
│ └── seo-props.ts # buildAstroSeoProps
|
|
3016
|
+
├── AGENTS.md # This file
|
|
3017
|
+
├── README.md # Project overview
|
|
3018
|
+
└── pnpm-workspace.yaml
|
|
3019
|
+
```
|