Skip to content

Python

Simple: a minimal payload

from easeo import SEOConfig, SEOEntity, build_seo_payload

config = SEOConfig(
    canonical_host="example.com",
    public_base_url="https://example.com",
)

entity = SEOEntity(
    entity_type="post",
    title="Hello World",
    excerpt="An example post.",
)

payload = build_seo_payload(entity, "/blog/hello", config)
print(payload.render_html())

Simple: a structured entity

from easeo import SEOEntityBuilder

entity = (
    SEOEntityBuilder("post")
    .title("Introducing easeo")
    .excerpt("Deterministic SEO payloads.")
    .featured_image("https://cdn.example.com/hero.jpg", width=1200, height=630, alt="Hero")
    .author_name("Jane Doe")
    .published_at("2026-01-15")
    .breadcrumb("Home", "/")
    .breadcrumb("Blog", "/blog")
    .build()
)

Simple: per-call overrides

from easeo import SEOOverrides, build_seo_payload

payload = build_seo_payload(
    entity,
    "/blog/hello",
    config,
    SEOOverrides(
        meta_title="A one-off title",
        skip_title_template=True,
        twitter_creator="@easeo",
    ),
)

Simple: serialize

payload.to_dict()      # canonical snake_case dict
payload.to_json()      # canonical JSON string
payload.hash()         # SHA-256 hex
payload.etag()         # quoted HTTP ETag

Complex: a full site config

from easeo import (
    SEOConfig,
    URLPolicy,
    Robots,
    SEOImage,
)

config = SEOConfig(
    canonical_host="shop.example.com",
    public_base_url="https://shop.example.com",
    url_policy=URLPolicy(
        enforce_https=True,
        lowercase_paths=True,
        trailing_slash="never",
        collapse_duplicate_slashes=True,
        strip_tracking_params=True,
        allowed_query_params=["page", "q"],
    ),
    site_name="Example Shop",
    title_template="{title} - Example Shop",
    default_robots=Robots(index=True, follow=True),
    search_robots=Robots(index=False, follow=True),
    default_og_image=SEOImage(
        url="https://shop.example.com/assets/og-image.png",
        width=1200,
        height=630,
        alt="Example Shop",
    ),
    publisher_name="Example Shop",
    publisher_logo="https://shop.example.com/assets/logo.png",
    locale="en_US",
    locale_alternate=["es_UY", "pt_BR"],
    twitter_site="@exampleshop",
    auto_generate_schema=True,
    search_url_template="https://shop.example.com/search?q={search_term_string}",
)

Complex: build from real content objects

A small helper that turns a model instance into an entity, plus overrides for the one field that needs to differ:

from easeo import SEOEntity, SEOOverrides, build_seo_payload

def entity_from_product(product):
    return SEOEntity(
        entity_type="product",
        title=product.name,
        excerpt=product.short_description,
        featured_image=product.image.url if product.image else None,
        sku=product.sku,
        price=str(product.price),
        price_currency=product.currency,
        availability="InStock" if product.in_stock else "OutOfStock",
        breadcrumbs=[
            # Breadcrumb(name=..., url=...) entries
        ],
        updated_at=product.updated_at.isoformat(),
    )

payload = build_seo_payload(
    entity_from_product(product),
    f"/products/{product.slug}",
    config,
    SEOOverrides(og_image=product.social_card) if product.social_card else None,
)

cache_headers = {
    "ETag": payload.etag(),
    "Cache-Control": "public, max-age=300",
}

Complex: hooks for a site-wide schema

A hook adds an Organization node to every payload. Hooks are attached to the config, so the builder stays a pure function of its inputs:

from easeo import HookRegistry, SEOConfig

hooks = HookRegistry()

@hooks.hook("post_process")
def inject_organization(payload, entity, config):
    org = {
        "@context": "https://schema.org",
        "@type": "Organization",
        "name": config.publisher_name or "Example",
        "url": config.public_base_url,
    }
    existing = payload.get("schema_jsonld")
    if isinstance(existing, list):
        payload["schema_jsonld"] = [org, *existing]
    elif existing is not None:
        payload["schema_jsonld"] = [org, existing]
    else:
        payload["schema_jsonld"] = org
    return payload

config = SEOConfig(..., hooks=hooks)

See Hooks for ordering, scoping, and error behavior.

Complex: a registered schema generator

Register one generator per schema type and it replaces the built-in schema whenever the resolved @type matches:

from easeo import SchemaRegistry, SEOConfig

registry = SchemaRegistry()

@registry.register("Article")
def podcast_episode(entity, config, canonical, title, description, og_image):
    return {
        "@context": "https://schema.org",
        "@type": "PodcastEpisode",
        "name": title,
        "url": canonical,
        "description": description,
        "associatedMedia": {"@type": "AudioObject", "contentUrl": entity.body_html},
    }

config = SEOConfig(..., schema_registry=registry)

Return None from a generator to fall back to the built-in schema for that page. See Custom JSON-LD.

Complex: async with FastAPI

from easeo import build_seo_payload_async

async def get_seo(product, route):
    payload = await build_seo_payload_async(
        entity_from_product(product), route, config
    )
    return payload.to_dict()

The Rust core releases the GIL, so the build runs off the event loop.

Complex: contracts and validation

from easeo import (
    SEOContractConfig,
    SEOContractRule,
    SEOExpectation,
    build_seo_contract,
)

contract = build_seo_contract(
    SEOContractConfig(
        canonical_host="shop.example.com",
        scheme="https",
        defaults=SEOExpectation(
            title=SEOExpectation(min_length=20, max_length=60),
            description=SEOExpectation(min_length=70, max_length=160),
            og_required=True,
            schema_required=True,
        ),
        rules=[
            SEOContractRule(
                match="/blog/*",
                expect=SEOExpectation(schema_types=["Article"]),
            ),
            SEOContractRule(
                match="/search",
                expect=SEOExpectation(indexable=False),
            ),
        ],
    )
)

contract.write(".easeo/contract.json")

Validate a built payload against best practices:

from easeo import validate_payload

for issue in validate_payload(payload):
    print(issue.rule_id, issue.severity, issue.message)

Set emit_warnings=True on the config to have those issues emitted as Python warnings during the build. See Validation for the rule list.

Complex: determinism check in tests

def test_seo_is_stable():
    a = build_seo_payload(entity, "/blog/hello", config)
    b = build_seo_payload(entity, "/blog/hello", config)
    assert a == b
    assert a.hash() == b.hash()

def test_seo_snapshot():
    payload = build_seo_payload(entity, "/blog/hello", config)
    assert payload == expected_payload  # committed fixture