# SEOntology — full content (version 0.1.0)
# SEOntology
**SEOntology** is an open, Schema.org-aligned OWL 2 ontology for modeling search engine optimization work as a knowledge graph: web pages and their URLs, the queries they rank for, links and link groups, content chunks, search and analytics metrics, quality scores, and structured-data profiles. Agents, applications and researchers can use it as a shared vocabulary to reason about, audit and optimize web content.
!!! success "Best Student Paper at SEMANTiCS 2026"
The paper *SEOntology: A Domain Ontology for Semantic Modeling of Search Engine Optimization Workflows* by Emilija Gjorgjevska, David Riccitelli, Milos Jovanovik and Andrea Volpini won [Best Student Paper at SEMANTiCS 2026](https://2026-eu.semantics.cc/page/awards.html) (Research Track).

## At a glance
| | |
|---|---|
| Namespace | `https://w3id.org/seovoc/` |
| Preferred prefix | `seovoc:` |
| Builds on | [Schema.org](https://schema.org/) |
| License | MIT |
| Source | [github.com/seontology/seontology](https://github.com/seontology/seontology) |
## Start here
- **New to SEOntology?** Read [Getting started](guide/getting-started.md) for a first example in JSON-LD, Turtle and SPARQL.
- **Modeling your data?** The [core model](guide/core-model.md) explains how pages, queries, links, chunks and metrics fit together. The [ontology reference](https://seontology.github.io/seontology/reference/) documents every class and property.
- **Building an agent or tool?** See [For agents](for-agents.md) for the machine-readable files: the ontology in Turtle, RDF/XML and JSON-LD, a JSON-LD context, and `llms.txt`.
- **Proposing a change?** Follow [Contributing ontology proposals](contributing-ontology-proposals.md).
## Use cases
- AI-powered content summarization and metadata generation
- Semantic internal linking via chunk and query similarity
- Entity linking and disambiguation in web content
- Graph-based internal linking optimization
- Query clustering and intent modeling for content strategy
- Training goal-relevant reasoning models using SEOntology and GRPO
- Provenance tracking for SEO actions and content evolution
- Multilingual SEO alignment and semantic consistency
- SERP feature optimization for enhanced search visibility
- Automated image captioning and SEO optimization
## Sponsor
SEOntology was initially developed by [WordLift](https://wordlift.io/) and is enriched by contributions from SEO experts, knowledge engineers and developers.
# Getting started
SEOntology terms live in the namespace `https://w3id.org/seovoc/`, usually written with the prefix `seovoc:`. A term IRI such as `https://w3id.org/seovoc/WebPage` resolves to its documentation in a browser and to the ontology file for RDF clients.
## Get the ontology
| Format | File |
|---|---|
| Turtle | [`seovoc.ttl`](https://seontology.github.io/seontology/seovoc.ttl) |
| RDF/XML | [`seovoc.owl`](https://seontology.github.io/seontology/seovoc.owl) |
| JSON-LD | [`seovoc.jsonld`](https://seontology.github.io/seontology/seovoc.jsonld) |
| JSON-LD context | [`context.jsonld`](https://seontology.github.io/seontology/context.jsonld) |
You can also open the ontology in [Protégé](https://protege.stanford.edu/) or explore it visually with [WebVOWL](https://service.tib.eu/webvowl/).
## A first example in JSON-LD
The example below describes one page: its canonical URL, 28-day Search Console metrics, a query it ranks for, a content chunk, and two link groups: footer boilerplate and an editorial link whose destination is a page in the same graph. Every `Link` states its href with `linkTargetUrl`; `linkTarget` adds the resolved destination node once you have one. The SEOntology JSON-LD context maps short names such as `hasQuery` to their `seovoc:` IRIs and gives numeric metrics their XSD datatypes.
```json
{
"@context": "https://w3id.org/seovoc/context.jsonld",
"@id": "https://example.com/blue-widgets/",
"@type": "WebPage",
"title": "Blue widgets",
"metaDescription": "Compare our range of blue widgets.",
"hasURL": {
"@id": "https://example.com/blue-widgets/#url",
"@type": "URL",
"value": "https://example.com/blue-widgets/"
},
"clicks28Days": 1240,
"impressions28Days": 38110,
"hasQuery": [
{
"@id": "https://example.com/queries/blue-widgets",
"@type": "Query",
"name": "blue widgets",
"clicks28Days": 320,
"averagePosition28Days": 3.4
}
],
"hasChunk": [
{
"@type": "Chunk",
"chunkPosition": 1,
"chunkText": "Blue widgets come in three sizes..."
}
],
"hasLinkGroup": [
{
"@type": "LinkGroup",
"linkGroupName": "Footer links",
"linkGroupType": "footer",
"hasLink": [
{
"@type": "Link",
"linkText": "About us",
"linkTargetUrl": "https://example.com/about/"
}
]
},
{
"@type": "LinkGroup",
"linkGroupName": "Related guides",
"linkGroupType": "body",
"hasLink": [
{
"@type": "Link",
"position": 1,
"linkText": { "@value": "how to choose a widget", "@language": "en" },
"linkTargetUrl": "https://example.com/guides/choosing-widgets/",
"linkTarget": "https://example.com/guides/choosing-widgets/"
}
]
}
]
}
```
!!! note "Mixing with Schema.org"
The context defines SEOntology terms only. Several of them (`name`, `about`, `mentions`, `text`) share a local name with Schema.org properties, and with this context they map to `seovoc:`. To use Schema.org properties in the same document, write them with a prefix, for example `"schema:headline"`. The context declares the `schema:` prefix.
## The same data in Turtle
```turtle
@prefix seovoc: .
@prefix xsd: .
a seovoc:WebPage ;
seovoc:title "Blue widgets" ;
seovoc:metaDescription "Compare our range of blue widgets." ;
seovoc:hasURL ;
seovoc:clicks28Days "1240"^^xsd:long ;
seovoc:impressions28Days "38110"^^xsd:long ;
seovoc:hasQuery ;
seovoc:hasChunk [
a seovoc:Chunk ;
seovoc:chunkPosition "1"^^xsd:int ;
seovoc:chunkText "Blue widgets come in three sizes..."
] ;
seovoc:hasLinkGroup [
a seovoc:LinkGroup ;
seovoc:linkGroupName "Footer links" ;
seovoc:linkGroupType "footer" ;
seovoc:hasLink [
a seovoc:Link ;
seovoc:linkText "About us" ;
seovoc:linkTargetUrl "https://example.com/about/"
]
] , [
a seovoc:LinkGroup ;
seovoc:linkGroupName "Related guides" ;
seovoc:linkGroupType "body" ;
seovoc:hasLink [
a seovoc:Link ;
seovoc:position 1 ;
seovoc:linkText "how to choose a widget"@en ;
seovoc:linkTargetUrl "https://example.com/guides/choosing-widgets/" ;
seovoc:linkTarget
]
] .
a seovoc:URL ;
seovoc:value "https://example.com/blue-widgets/" .
a seovoc:Query ;
seovoc:name "blue widgets" ;
seovoc:clicks28Days "320"^^xsd:long ;
seovoc:averagePosition28Days "3.4"^^xsd:double .
```
## Query it with SPARQL
Find pages and the queries they rank for, ordered by the query's 28-day clicks:
```sparql
PREFIX seovoc:
SELECT ?page ?title ?query ?clicks ?position
WHERE {
?page a seovoc:WebPage ;
seovoc:title ?title ;
seovoc:hasQuery ?q .
?q seovoc:name ?query ;
seovoc:clicks28Days ?clicks .
OPTIONAL { ?q seovoc:averagePosition28Days ?position }
}
ORDER BY DESC(?clicks)
```
Find the pages that link to a destination, with their anchor text and placement, excluding boilerplate navigation. The query joins on the `linkTargetUrl` string, so it also finds links whose destination was never resolved to a node:
```sparql
PREFIX seovoc:
SELECT ?page ?text ?placement
WHERE {
?link seovoc:linkTargetUrl "https://example.com/guides/choosing-widgets/" .
OPTIONAL { ?link seovoc:linkText ?text }
{
?page seovoc:hasLinkGroup ?group .
?group seovoc:hasLink ?link .
OPTIONAL { ?group seovoc:linkGroupType ?placement }
} UNION {
?page seovoc:link ?link .
OPTIONAL { ?link seovoc:linkLocation ?placement }
}
FILTER (!BOUND(?placement) || ?placement NOT IN ("header", "footer", "navigation"))
}
```
## Next steps
- Read the [core model](core-model.md) to see how the main classes relate.
- Look up any term in the [ontology reference](https://seontology.github.io/seontology/reference/).
- See the [structured data profiles](../structured-data-profiles.md) for a complete worked example with SHACL shapes.
# Core model
SEOntology is centered on the **`WebPage`**: the pages a site publishes, the **queries** they rank for, the **links** between them, the **content** they contain, and the **measurements** taken about them. This page groups the main terms by area. The [ontology reference](https://seontology.github.io/seontology/reference/) lists every term with its domain, range and description.
## Pages and site structure
| Term | Meaning |
|---|---|
| `WebPage` | A specific web page; extends Schema.org's `WebPage`. |
| `URL` | The page's URL. `hasURL` / `isURLOf` binds a page to its canonical URL. |
| `Domain` | A grouping of pages by domain or subdomain. |
| `DirectorySegment` | A logical site segment derived from URL paths. |
| `PageType` | A reusable classification of a page's purpose (for example product detail or editorial), assigned with `hasPageType`. |
| `PageGroup` | An A/B-testing cohort, such as a control or test group. Use `PageType` for functional classifications. |
| `Language` | The language of content, for multilingual SEO. |
Page-level text properties include `title`, `metaDescription`, `bodyText`, `urlSlug` and `slugNgram`.
## Queries and intent
| Term | Meaning |
|---|---|
| `Query` | A search query a page ranks for, usually from Google Search Console. `hasQuery` / `isQueryOf` link pages and queries. |
| `hasPrimaryQuery` | The primary target query of a `WebPage` or `Chunk`. |
| `QueryCluster` | A set of related queries grouped for content planning and gap analysis. Members are linked with `hasClusterQuery` / `isClusterQueryOf`. |
| `ClusteringStrategy` | The method or model used to build a cluster (`clusteredBy`). |
| `Intent` | Search intent, linked with `hasIntent`; a cluster's dominant intent uses `dominantSearchIntent`. |
| `EntityGap` | A missing or unlinked entity relevant to a query (`hasEntityGap`). |
| `Persona` | The target audience archetype of a page (`hasPersona`). |
Query classification properties include `queryType`, `keywordType`, `queryCategory` and `intent`. Cluster metrics include `clusterVolume` and `keywordsCount`.
## Links
Each hyperlink on a page is a **`Link`**. A page reaches its links in one of two ways:
- **Grouped**: **`hasLinkGroup`** from the `WebPage` to a **`LinkGroup`**, then **`hasLink`** from the group to each `Link`. This is the canonical pattern for navigation, footers, related-content blocks and other clusters of links.
- **Ungrouped**: the direct **`link`** property from `WebPage` to `Link`, for raw extracted links or a single inline link in body text.
A `LinkGroup` carries `linkGroupName`, free text for display ("Footer links"), `linkGroupType`, the machine-checkable placement (`header`, `footer`, `body`, `sidebar` or `navigation`), and `linkGroupID`.
A `Link` describes one hyperlink and where it points:
| Term | Meaning |
|---|---|
| `linkTargetUrl` | The absolute href, as a string. Always set it: it is the destination fact you have from parsing the markup alone. |
| `linkTarget` | The resolved destination node: a `WebPage` in the graph, a `URL` node, or the entity the destination represents. Set it only once the destination has been resolved. Its inverse is `isLinkTargetOf`. |
| `linkText` | The anchor text. Accepts language-tagged literals. |
| `linkRel` | One `rel` token per value: `nofollow`, `sponsored`, `ugc`, `noopener`, and so on. |
| `linkLocation` | Placement of an ungrouped link, with the same values as `linkGroupType`. When a `LinkGroup` exists, its `linkGroupType` takes precedence. |
| `position` | Order of the link within its group. |
Internal versus external is not a property of the link. Compare domains instead: a link is internal when its `linkTarget` page shares a `Domain` (`hasDomain`) with the source page, or, for destinations that were not crawled, when the `linkTargetUrl` host matches the source page's `domainName`.
`anchorResource`, `linkgroupID`, `noFollow`, `linkType` and the `AnchorText` class are deprecated. See [Compatibility notes](compatibility.md#links).
The [Links](links.md) guide explains the model in depth, with worked examples and queries.
## Content and entities
| Term | Meaning |
|---|---|
| `Chunk` | A self-contained section of a page, used for vector search and structured representation. Linked with `hasChunk` / `isChunkOf`; described by `chunkText`, `chunkPosition`, `tokenCount` and related properties. |
| `Topic` | The central subject of a page. |
| `schema:Thing` | Any entity. `about` links a page to its main entity and `mentions` to entities it mentions; both range over Schema.org's `Thing`. |
| `ImageObject` | An image and its SEO metadata, with `aiGeneratedCaption` and `captionQualityScore`. |
| `Model` | An AI or ML model used for summarization, classification, scoring or captioning. |
Embedding inputs and outputs are recorded with `embeddingText` and `embeddingValue`.
## Search and analytics metrics
- **Search Console**: `clicks`, `impressions` and `ctr`, plus windowed variants for 7 days, 28 days, 3 months and 6 months (for example `clicks28Days`, `impressions3Months`, `ctr6Months`, `averagePosition28Days`). Windowed metrics can be attached to a `Query` or a `WebPage`.
- **GA4**: `users`, `sessions`, `engagedSessions`, `engagementRate`, `averageEngagementTime`, `conversions`, `trafficSource` and `landingPage`, interpreted in the measurement context of the dataset that produced them.
- **Traffic sources**: a `TrafficSource` is a source as reported by analytics (`google`, `(direct)`, `chatgpt.com`). `hasTrafficChannel` assigns it to a `TrafficChannel` such as organic search, referral or AI assistant, so AI-assistant referrals can be told apart from generic referrals.
- **Forecasts**: `forecastedTraffic`.
## Quality and evaluation
Link a `WebPage` to a **`QualityScore`** with `hasQualityScore`, or a `Chunk` with `hasChunkQualityScore`, to record assessment results such as `contentAccuracyScore`, `contentDepthScore`, `seoScore` and `readabilityScore`. `evaluationID` identifies an evaluation run. `isThinContent` is a boolean thin-content classification.
## Structured data
`Schema` represents the structured-data markup of a page (`hasSchemaMarkup` / `describesPage`). `StructuredDataProfile` describes a structured-data policy aligned with W3C PROF. Profiles can be applicable to a page type, selected for a page, or used to generate markup; these are separate states. See [Structured data profiles](../structured-data-profiles.md).
# Links
A **`Link`** is one hyperlink found on a `WebPage`: an `` element, with its anchor text, its `rel` tokens, its place in the layout and its destination. With destinations recorded, the links of a site form a graph, and the usual internal-linking work becomes a query over that graph:
- Which pages link to a given page, with what anchor text, and from which part of the layout?
- Which of those anchors are editorial, and which are site-wide navigation or footer boilerplate?
- Which internal links point at pages that return an error?
- Which internal links carry `nofollow`?
!!! tip "Short version"
Emit one `Link` per ``, with `linkTargetUrl` (the href) and `linkText` (the anchor). Put navigation, footer and other blocks of links in a `LinkGroup` with a `linkGroupType`. Add `linkTarget` (the destination node) only once you have resolved the href to a page or entity.
On this page, the **href** is the URL written in the markup, recorded with `linkTargetUrl`, and the **destination** is the node that href resolves to, recorded with `linkTarget`.
## The model at a glance
```mermaid
flowchart TB
P[WebPage] -- hasLinkGroup --> G[LinkGroup]
G -- hasLink --> L[Link]
P -- link --> L
L -- linkTarget --> T[WebPage, URL or entity]
```
A page reaches its links in one of two ways:
- **Grouped**: `hasLinkGroup` from the `WebPage` to a **`LinkGroup`**, then `hasLink` from the group to each `Link`. Use this for blocks of related links: the main navigation, the footer, a sidebar, a "Related insights" block.
- **Ungrouped**: `link` straight from the `WebPage` to the `Link`. Use this for raw extracted links before any grouping pass, and for single inline links in body text.
### Link
| Term | Value | Notes |
|---|---|---|
| `linkTargetUrl` | string | The href, absolute and resolved against the page's base URL. Always set it. |
| `linkTarget` | node | The destination: a `WebPage`, a `URL` node or an entity. Set it only once resolved. Inverse: `isLinkTargetOf`. |
| `linkText` | literal | The anchor text. May carry a language tag. |
| `linkRel` | string | One `rel` token per value: `nofollow`, `sponsored`, `ugc`, `noopener`, and so on. |
| `linkLocation` | placement | Placement of an ungrouped link. |
| `position` | number | Order of the link within its group. |
### LinkGroup
| Term | Value | Notes |
|---|---|---|
| `hasLink` | `Link` | Each link in the group. Inverse: `isLinkOf`. |
| `linkGroupType` | placement | Placement of the whole group. Takes precedence over `linkLocation`. |
| `linkGroupName` | string | Display name, for example "Footer links". |
| `linkGroupID` | string | Identifier of the group. |
### WebPage
| Term | Value | Notes |
|---|---|---|
| `hasLinkGroup` | `LinkGroup` | Grouped links. Inverse: `isLinkGroupOf`. |
| `link` | `Link` | Ungrouped links. |
**placement** is one of `header`, `footer`, `body`, `sidebar` or `navigation`. The [ontology reference](https://seontology.github.io/seontology/reference/) lists the formal domain and range of each term.
## Why it is modeled this way
**Two properties for where a link points.** A parser knows the href, a string. A resolver knows, later, the node the href stands for: a page in the graph, a `URL` node, or an entity. OWL 2 DL does not allow one property to take both a string and a node, so there are two: `linkTargetUrl` for the href, always present, and `linkTarget` for the destination, present once resolved. Because `linkTarget` ranges over `schema:Thing`, one property covers every kind of destination, and a query narrows it with a type filter.
**The href belongs to the link, not to a page identity.** Many links share one href, and a page can link to the same URL several times. `linkTargetUrl` is a plain value on the `Link`, so two links to the same href simply share a value. It is deliberately not a `URL` node via `hasURL`: that property is functional and inverse-functional, which makes the `URL` node a key for *page* identity.
**Placement is a fixed set of values.** `linkGroupType` and `linkLocation` share one enumeration, so a consumer can filter out boilerplate without guessing at free text. When a link sits in a `LinkGroup`, the group's `linkGroupType` applies and `linkLocation` is redundant. `linkLocation` exists for ungrouped links, which have no group to carry their placement.
**Internal or external is derived, not stored.** A link is internal when its source page and its destination share a `Domain`. Comparing domains handles subdomains and multi-domain brands, which a stored `internal` / `external` flag cannot, and it never goes stale when a site's domain setup changes.
**`rel` is a list of tokens.** HTML allows several `rel` tokens on one link, for example `nofollow noopener`. `linkRel` holds one token per value, so `sponsored` and `ugc` are as easy to express as `nofollow`, and no separate boolean can contradict them.
## Examples
Each example is shown in three syntaxes that produce the same RDF graph. Pick one and every example on the page switches to it.
- **JSON-LD** is what most producers emit.
- **YAML-LD** is JSON-LD written as YAML ([W3C Working Draft](https://www.w3.org/TR/yaml-ld-10/), media type `application/ld+yaml`). It allows comments, so **comments explaining each property appear in the YAML-LD tab**.
- **Turtle** is what you load into a triple store.
The JSON-LD and YAML-LD examples use the SEOntology JSON-LD context, `https://w3id.org/seovoc/context.jsonld`, which maps short names such as `linkTargetUrl` to their `seovoc:` IRIs. The Turtle examples set `@base `, so `` stands for `https://example.com/outlook/`.
### 1. Minimal link
The least a crawler can say about a link, and enough to start building a link graph: the page, the anchor text and the href. The link sits inline in body text, so it is ungrouped and states its own placement with `linkLocation`.
=== "JSON-LD"
```json
{
"@context": "https://w3id.org/seovoc/context.jsonld",
"@id": "https://example.com/outlook/",
"@type": "WebPage",
"link": {
"@type": "Link",
"linkText": "where wealth managers see 2026 risk",
"linkTargetUrl": "https://example.com/wealth/",
"linkLocation": "body"
}
}
```
=== "YAML-LD"
```yaml
"@context": https://w3id.org/seovoc/context.jsonld
"@id": https://example.com/outlook/
"@type": WebPage
# An inline link in body text, so it is ungrouped
link:
"@type": Link
# The anchor text, as it appears on the page
linkText: where wealth managers see 2026 risk
# The href, made absolute against the page's URL
linkTargetUrl: https://example.com/wealth/
# Placement, because there is no LinkGroup to carry it
linkLocation: body
```
=== "Turtle"
```turtle
@base .
@prefix seovoc: .
a seovoc:WebPage ;
seovoc:link [
a seovoc:Link ;
seovoc:linkText "where wealth managers see 2026 risk" ;
seovoc:linkTargetUrl "https://example.com/wealth/" ;
seovoc:linkLocation "body"
] .
```
### 2. Navigation group
The main navigation of the same page, as a `LinkGroup`. The first link has been resolved to a page in the graph, so it carries `linkTarget` as well as `linkTargetUrl`, and the destination's `status` is a single lookup away. The second link points at a page that has not been crawled, so it has an href and no destination.
=== "JSON-LD"
```json
{
"@context": "https://w3id.org/seovoc/context.jsonld",
"@graph": [
{
"@id": "https://example.com/outlook/",
"@type": "WebPage",
"hasLinkGroup": {
"@id": "https://example.com/outlook/#nav",
"@type": "LinkGroup",
"linkGroupID": "main-nav",
"linkGroupName": "Main navigation",
"linkGroupType": "navigation",
"hasLink": [
{
"@type": "Link",
"position": 1,
"linkText": "Wealth Management",
"linkTargetUrl": "https://example.com/wealth/",
"linkTarget": "https://example.com/wealth/"
},
{
"@type": "Link",
"position": 2,
"linkText": "Investment Banking",
"linkTargetUrl": "https://example.com/banking/"
}
]
}
},
{
"@id": "https://example.com/wealth/",
"@type": "WebPage",
"status": 200
}
]
}
```
=== "YAML-LD"
```yaml
"@context": https://w3id.org/seovoc/context.jsonld
"@graph":
- "@id": https://example.com/outlook/
"@type": WebPage
# A block of links on this page
hasLinkGroup:
"@id": https://example.com/outlook/#nav
"@type": LinkGroup
# Identifier of the group, a string
linkGroupID: main-nav
# Display name, usually a heading or landmark label
linkGroupName: Main navigation
# Placement of every link in the group
linkGroupType: navigation
hasLink:
- "@type": Link
# Order within the group
position: 1
linkText: Wealth Management
linkTargetUrl: https://example.com/wealth/
# Resolved: the destination is a page in this graph
linkTarget: https://example.com/wealth/
- "@type": Link
position: 2
linkText: Investment Banking
# Not crawled yet: an href, but no destination
linkTargetUrl: https://example.com/banking/
# The destination page, in the same graph
- "@id": https://example.com/wealth/
"@type": WebPage
# HTTP status of the destination
status: 200
```
=== "Turtle"
```turtle
@base .
@prefix seovoc: .
@prefix xsd: .
a seovoc:WebPage ;
seovoc:hasLinkGroup .
a seovoc:LinkGroup ;
seovoc:linkGroupID "main-nav" ;
seovoc:linkGroupName "Main navigation" ;
seovoc:linkGroupType "navigation" ;
seovoc:hasLink [
a seovoc:Link ;
seovoc:position 1 ;
seovoc:linkText "Wealth Management" ;
seovoc:linkTargetUrl "https://example.com/wealth/" ;
seovoc:linkTarget
] , [
a seovoc:Link ;
seovoc:position 2 ;
seovoc:linkText "Investment Banking" ;
seovoc:linkTargetUrl "https://example.com/banking/"
] .
a seovoc:WebPage ;
seovoc:status "200"^^xsd:int .
```
### 3. Two anchors, one target
The same page linked twice from one page: from the navigation with a generic anchor, and from an editorial "Related insights" block with a descriptive one. The two links are separate `Link` nodes with the same `linkTargetUrl` value, which is how you compare the anchors that point at one page. The editorial anchor carries a language tag.
=== "JSON-LD"
```json
{
"@context": "https://w3id.org/seovoc/context.jsonld",
"@id": "https://example.com/outlook/",
"@type": "WebPage",
"hasLinkGroup": [
{
"@type": "LinkGroup",
"linkGroupName": "Main navigation",
"linkGroupType": "navigation",
"hasLink": {
"@type": "Link",
"linkText": "Wealth Management",
"linkTargetUrl": "https://example.com/wealth/"
}
},
{
"@type": "LinkGroup",
"linkGroupName": "Related insights",
"linkGroupType": "body",
"hasLink": {
"@type": "Link",
"linkText": {
"@value": "where wealth managers see 2026 risk",
"@language": "en"
},
"linkTargetUrl": "https://example.com/wealth/"
}
}
]
}
```
=== "YAML-LD"
```yaml
"@context": https://w3id.org/seovoc/context.jsonld
"@id": https://example.com/outlook/
"@type": WebPage
hasLinkGroup:
# Boilerplate: repeated on every page of the site
- "@type": LinkGroup
linkGroupName: Main navigation
linkGroupType: navigation
hasLink:
"@type": Link
# Generic anchor, chosen for navigation
linkText: Wealth Management
linkTargetUrl: https://example.com/wealth/
# Editorial: specific to this page
- "@type": LinkGroup
linkGroupName: Related insights
linkGroupType: body
hasLink:
"@type": Link
# Descriptive anchor, with a language tag
linkText:
"@value": where wealth managers see 2026 risk
"@language": en
# The same href as the navigation link
linkTargetUrl: https://example.com/wealth/
```
=== "Turtle"
```turtle
@base .
@prefix seovoc: .
a seovoc:WebPage ;
seovoc:hasLinkGroup [
a seovoc:LinkGroup ;
seovoc:linkGroupName "Main navigation" ;
seovoc:linkGroupType "navigation" ;
seovoc:hasLink [
a seovoc:Link ;
seovoc:linkText "Wealth Management" ;
seovoc:linkTargetUrl "https://example.com/wealth/"
]
] , [
a seovoc:LinkGroup ;
seovoc:linkGroupName "Related insights" ;
seovoc:linkGroupType "body" ;
seovoc:hasLink [
a seovoc:Link ;
seovoc:linkText "where wealth managers see 2026 risk"@en ;
seovoc:linkTargetUrl "https://example.com/wealth/"
]
] .
```
### 4. Outbound link with rel
A link to another site, with two `rel` tokens. `linkRel` takes one value per token, so the full list is kept: `nofollow`, `sponsored` and `ugc` are hints search engines read, and `noopener` matters for security audits. There is no `linkTarget`: the destination is outside the graph, so the href is all there is to record.
=== "JSON-LD"
```json
{
"@context": "https://w3id.org/seovoc/context.jsonld",
"@id": "https://example.com/outlook/",
"@type": "WebPage",
"link": {
"@type": "Link",
"linkText": "the Federal Reserve's policy statement",
"linkTargetUrl": "https://www.federalreserve.gov/monetarypolicy.htm",
"linkLocation": "body",
"linkRel": ["nofollow", "noopener"]
}
}
```
=== "YAML-LD"
```yaml
"@context": https://w3id.org/seovoc/context.jsonld
"@id": https://example.com/outlook/
"@type": WebPage
link:
"@type": Link
linkText: the Federal Reserve's policy statement
# Another site: an href, and no destination node
linkTargetUrl: https://www.federalreserve.gov/monetarypolicy.htm
linkLocation: body
# Every rel token in the markup, one value each
linkRel:
- nofollow
- noopener
```
=== "Turtle"
```turtle
@base .
@prefix seovoc: .
a seovoc:WebPage ;
seovoc:link [
a seovoc:Link ;
seovoc:linkText "the Federal Reserve's policy statement" ;
seovoc:linkTargetUrl
"https://www.federalreserve.gov/monetarypolicy.htm" ;
seovoc:linkLocation "body" ;
seovoc:linkRel "nofollow" , "noopener"
] .
```
### 5. Internal vs external
Whether a link is internal is not stored on the link. Give pages a `Domain` with `hasDomain`: a link is internal when its destination page has the same `Domain` as the source page. For a destination that was never crawled, compare the host of its `linkTargetUrl` with the source page's `domainName`.
=== "JSON-LD"
```json
{
"@context": "https://w3id.org/seovoc/context.jsonld",
"@graph": [
{
"@id": "https://example.com/outlook/",
"@type": "WebPage",
"hasDomain": "https://example.com/#domain",
"link": [
{
"@type": "Link",
"linkText": "where wealth managers see 2026 risk",
"linkTargetUrl": "https://example.com/wealth/",
"linkTarget": "https://example.com/wealth/"
},
{
"@type": "Link",
"linkText": "the Federal Reserve's policy statement",
"linkTargetUrl": "https://www.federalreserve.gov/monetarypolicy.htm"
}
]
},
{
"@id": "https://example.com/wealth/",
"@type": "WebPage",
"hasDomain": "https://example.com/#domain"
},
{
"@id": "https://example.com/#domain",
"@type": "Domain",
"domainName": "example.com"
}
]
}
```
=== "YAML-LD"
```yaml
"@context": https://w3id.org/seovoc/context.jsonld
"@graph":
- "@id": https://example.com/outlook/
"@type": WebPage
# The source page's domain
hasDomain: https://example.com/#domain
link:
# Internal: the destination has the same Domain
- "@type": Link
linkText: where wealth managers see 2026 risk
linkTargetUrl: https://example.com/wealth/
linkTarget: https://example.com/wealth/
# External: no destination, and the href host
# differs from the source page's domainName
- "@type": Link
linkText: the Federal Reserve's policy statement
linkTargetUrl: https://www.federalreserve.gov/monetarypolicy.htm
- "@id": https://example.com/wealth/
"@type": WebPage
# The same Domain node: this is what "internal" means
hasDomain: https://example.com/#domain
- "@id": https://example.com/#domain
"@type": Domain
# The host, for comparing with hrefs that were not crawled
domainName: example.com
```
=== "Turtle"
```turtle
@base .
@prefix seovoc: .
a seovoc:WebPage ;
seovoc:hasDomain <#domain> ;
seovoc:link [
a seovoc:Link ;
seovoc:linkText "where wealth managers see 2026 risk" ;
seovoc:linkTargetUrl "https://example.com/wealth/" ;
seovoc:linkTarget
] , [
a seovoc:Link ;
seovoc:linkText "the Federal Reserve's policy statement" ;
seovoc:linkTargetUrl
"https://www.federalreserve.gov/monetarypolicy.htm"
] .
a seovoc:WebPage ;
seovoc:hasDomain <#domain> .
<#domain> a seovoc:Domain ;
seovoc:domainName "example.com" .
```
## Querying the link graph
=== "Inbound links"
Pages that link to a page, with anchor text and placement, excluding navigation and footer boilerplate. The query matches on the `linkTargetUrl` value, so it also finds links whose destination was never resolved, and it covers both grouped and ungrouped links.
```sparql
PREFIX seovoc:
SELECT ?page ?text ?placement
WHERE {
?link seovoc:linkTargetUrl "https://example.com/wealth/" .
OPTIONAL { ?link seovoc:linkText ?text }
{
?page seovoc:hasLinkGroup ?group .
?group seovoc:hasLink ?link .
OPTIONAL { ?group seovoc:linkGroupType ?placement }
} UNION {
?page seovoc:link ?link .
OPTIONAL { ?link seovoc:linkLocation ?placement }
}
FILTER (!BOUND(?placement)
|| ?placement NOT IN ("header", "footer", "navigation"))
}
```
=== "Broken links"
Links whose destination returns an HTTP error.
```sparql
PREFIX seovoc:
SELECT ?page ?href ?status
WHERE {
{ ?page seovoc:link ?link }
UNION
{ ?page seovoc:hasLinkGroup/seovoc:hasLink ?link }
?link seovoc:linkTargetUrl ?href ;
seovoc:linkTarget ?target .
?target seovoc:status ?status .
FILTER (?status >= 400)
}
```
=== "Nofollow internal"
Links that carry `nofollow` and point at a page on the source page's own domain.
```sparql
PREFIX seovoc:
SELECT ?page ?href
WHERE {
{ ?page seovoc:link ?link }
UNION
{ ?page seovoc:hasLinkGroup/seovoc:hasLink ?link }
?link seovoc:linkRel "nofollow" ;
seovoc:linkTargetUrl ?href ;
seovoc:linkTarget ?target .
?page seovoc:hasDomain ?domain .
?target seovoc:hasDomain ?domain .
}
```
## Guidance
These recommendations follow Google's documentation on [crawlable links](https://developers.google.com/search/docs/crawling-indexing/links-crawlable) and [qualifying outbound links](https://developers.google.com/search/docs/crawling-indexing/qualify-outbound-links), and common practice in crawlers that audit links.
!!! tip "Capturing links"
- **Record `` elements.** They are the links search engines reliably follow. Elements that act as links only through JavaScript, and `javascript:` hrefs, are not links in this sense.
- **Always set `linkTargetUrl`**, absolute and resolved against the page's base URL. Otherwise keep it as the page wrote it: do not replace it with the canonical URL or the URL after redirects. That belongs in `linkTarget`, which points at the destination's `WebPage` node.
- **Set `linkText` to the anchor as it appears on the page.** Keep empty or generic anchors ("click here", "read more") as they are: finding them is part of an audit.
- **Split `rel` into tokens**, one `linkRel` value each, lowercased. HTML separates tokens with spaces; split on commas as well, because some sites use them.
- **Use `position` for document order within a group.** It supports analysis; it does not imply that the first link to a URL counts more, which Google has not documented.
!!! tip "Placement"
- **Group links that belong together** (navigation, footer, a related-content block) in a `LinkGroup` and set its `linkGroupType`. Do not create a `LinkGroup` for one inline link in body text: use `link` and `linkLocation`.
- **Placement is a heuristic.** Crawlers classify it differently, from HTML landmarks such as `