Skip to content

FastAPI integration

Use build_seo_payload_async from seoslug.async_builder in your route handlers. It runs the synchronous builder in a thread pool so it does not block the event loop.

Basic route handler

from fastapi import FastAPI, Depends
from fastapi.responses import HTMLResponse
from seoslug import SEOConfig, URLPolicy, SEOEntity
from seoslug.async_builder import build_seo_payload_async

app = FastAPI()

def get_seo_config():
    return SEOConfig(
        canonical_host="blog.example.com",
        public_base_url="https://blog.example.com",
        url_policy=URLPolicy(),
    )

@app.get("/posts/{slug}", response_class=HTMLResponse)
async def get_post(slug: str, config: SEOConfig = Depends(get_seo_config)):
    entity = SEOEntity(
        entity_type="post",
        title="My Post",
        excerpt="A short description",
        status="published",
    )
    payload = await build_seo_payload_async(entity, f"/posts/{slug}", config)
    return HTMLResponse(f"""
    <head>
        <title>{payload.title}</title>
        <meta name="description" content="{payload.description}">
        <link rel="canonical" href="{payload.canonical}">
    </head>
    """)

Dependency injection pattern

Use FastAPI's Depends to provide SEOConfig. This keeps configuration centralised and testable.

from functools import lru_cache
from seoslug import SEOConfig, URLPolicy

@lru_cache
def get_seo_config():
    return SEOConfig(
        canonical_host="blog.example.com",
        public_base_url="https://blog.example.com",
        url_policy=URLPolicy(allowed_query_params=["page"]),
        site_name="My Blog",
        locale="en_US",
    )

Inject the config into any route that needs SEO metadata.

ETag caching

seoslug is deterministic. Same input always produces the same SEOPayload. Use this for ETag-based HTTP caching.

import hashlib
from fastapi import Request, Response

@app.get("/posts/{slug}")
async def get_post_cached(
    request: Request,
    slug: str,
    config: SEOConfig = Depends(get_seo_config),
):
    entity = SEOEntity(entity_type="post", title="My Post", status="published")
    payload = await build_seo_payload_async(entity, f"/posts/{slug}", config)
    etag = hashlib.sha256(str(payload.to_dict()).encode()).hexdigest()[:16]

    if request.headers.get("if-none-match") == etag:
        return Response(status_code=304)

    return Response(
        content=render_template(payload),
        headers={"ETag": etag},
    )

Template rendering with Jinja2

from fastapi.templating import Jinja2Templates

templates = Jinja2Templates(directory="templates")

@app.get("/posts/{slug}")
async def get_post(
    request: Request,
    slug: str,
    config: SEOConfig = Depends(get_seo_config),
):
    entity = SEOEntity(entity_type="post", title="My Post", status="published")
    payload = await build_seo_payload_async(entity, f"/posts/{slug}", config)
    return templates.TemplateResponse("post.html", {
        "request": request,
        "payload": payload.to_dict(),
    })

The SEOPayload dataclass is dict-compatible. You can pass it directly or use .to_dict() for explicit serialization.

Customising the thread pool

from concurrent.futures import ThreadPoolExecutor
from seoslug.async_builder import set_executor

executor = ThreadPoolExecutor(max_workers=8)
set_executor(executor)

Call set_executor(None) to reset to the default 4-worker pool.