Starlette integration¶
Starlette is an async Python framework. Use build_seo_payload_async from seoslug.async_builder in your endpoints. It runs the synchronous builder in a thread pool so it does not block the event loop.
Basic route¶
from starlette.applications import Starlette
from starlette.responses import HTMLResponse
from starlette.routing import Route
from seoslug import SEOConfig, URLPolicy, SEOEntity
from seoslug.async_builder import build_seo_payload_async
SEO_CONFIG = SEOConfig(
canonical_host="blog.example.com",
public_base_url="https://blog.example.com",
url_policy=URLPolicy(),
)
async def home(request):
entity = SEOEntity(
entity_type="home",
title="My Blog",
excerpt="A blog about things.",
status="published",
)
payload = await build_seo_payload_async(entity, "/", SEO_CONFIG)
html = render_jinja_template("home.html", seo=payload)
return HTMLResponse(html)
Route with path parameters¶
Starlette passes path parameters via request.path_params:
from starlette.responses import HTMLResponse
from seoslug import SEOConfig, URLPolicy, SEOEntity, Breadcrumb
from seoslug.async_builder import build_seo_payload_async
async def post_detail(request):
slug = request.path_params["slug"]
post = get_post_by_slug(slug)
if post is None:
from starlette.responses import PlainTextResponse
return PlainTextResponse("Not found", status_code=404)
entity = SEOEntity(
entity_type="post",
title=post.title,
excerpt=post.excerpt,
status="published",
published_at=post.published_at.isoformat(),
updated_at=post.updated_at.isoformat() if post.updated_at else None,
author_name=post.author,
featured_image=post.cover_url,
breadcrumbs=[
Breadcrumb(name="Blog", url="/"),
Breadcrumb(name=post.title, url=f"/{slug}/"),
],
)
payload = await build_seo_payload_async(entity, f"/{slug}/", SEO_CONFIG)
html = render_jinja_template("post.html", post=post, seo=payload)
return HTMLResponse(html)
App setup with routes¶
Wire everything together with Starlette's Route list:
from starlette.applications import Starlette
from starlette.routing import Route
routes = [
Route("/", home),
Route("/{slug}", post_detail),
]
app = Starlette(routes=routes)
Template rendering¶
Use any async-compatible template engine. Starlette ships with Jinja2 support via Jinja2Templates:
from starlette.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
async def post_detail(request):
slug = request.path_params["slug"]
post = get_post_by_slug(slug)
entity = SEOEntity(
entity_type="post",
title=post.title,
excerpt=post.excerpt,
status="published",
)
payload = await build_seo_payload_async(entity, f"/{slug}/", SEO_CONFIG)
return templates.TemplateResponse("post.html", {
"request": request,
"seo": payload,
"post": post,
})
In your Jinja2 template, render the full SEO block or individual parts:
Or compose the tags manually:
<head>
<title>{{ seo.title }}</title>
<meta name="description" content="{{ seo.description }}">
<link rel="canonical" href="{{ seo.canonical }}">
<meta name="robots" content="{{ seo.robots }}">
{{ seo.render_opengraph()|safe }}
{{ seo.render_twitter()|safe }}
{{ seo.render_jsonld()|safe }}
</head>
JSON API¶
For JSON endpoints, use build_seo_payload_async and call .to_dict() on the result, or use the synchronous build_seo_payload_dict:
from starlette.responses import JSONResponse
from seoslug import SEOEntity
from seoslug.async_builder import build_seo_payload_async
async def seo_json(request):
slug = request.path_params["slug"]
post = get_post_by_slug(slug)
if post is None:
return JSONResponse({"error": "not found"}, status_code=404)
entity = SEOEntity(
entity_type="post",
title=post.title,
excerpt=post.excerpt,
status="published",
)
payload = await build_seo_payload_async(entity, f"/{slug}/", SEO_CONFIG)
return JSONResponse(payload.to_dict())
Middleware pattern¶
For apps where every page needs SEO metadata, use middleware to attach the SEO payload to the request state:
from starlette.middleware.base import BaseHTTPMiddleware
from seoslug.async_builder import build_seo_payload_async
class SEOMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
route = request.url.path
entity = SEOEntity(
entity_type="page",
title=derive_title_from_route(route),
excerpt="",
status="published",
)
request.state.seo = await build_seo_payload_async(
entity, route, SEO_CONFIG
)
return await call_next(request)
ETag caching¶
Use seoslug's etag() for conditional responses:
from starlette.responses import Response
async def post_detail(request):
slug = request.path_params["slug"]
post = get_post_by_slug(slug)
entity = SEOEntity(
entity_type="post",
title=post.title,
excerpt=post.excerpt,
status="published",
)
payload = await build_seo_payload_async(entity, f"/{slug}/", SEO_CONFIG)
etag = payload.etag()
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
html = render_jinja_template("post.html", post=post, seo=payload)
return Response(html, media_type="text/html", headers={"ETag": etag})
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.