spec.seo and @insureco/seoA service that serves pages declares spec.seo in its catalog. On a prod deploy the builder registers the
service's custom domain with iec-webmaster (the platform's Search Console, Bing and IndexNow
service) and injects three runtime env vars. The @insureco/seo SDK turns those into a canonical
link, noindex on platform hosts, JSON-LD from the service's Entity, a sitemap, a robots file and
the IndexNow key file. No hand-written SEO code.
catalog-info.yaml (any service that serves pages — not worker — catalog 0.8.0):metadata:
annotations:
insureco.io/catalog-version: "0.8.0" # required — below this spec.seo is ignored
spec:
seo:
enabled: true
entity: raterspot # Brand Entity slug; defaults to the service's product
pages: sitemap # 'sitemap' (default) or an explicit list of paths
topic: insurance rating software # default Topic for pages that do not name one
.npmrc:npm install @insureco/seo
# .npmrc
@insureco:registry=https://git.insureco.io/api/packages/insureco/npm/
// app/layout.tsx — a Server Component
import { SeoHead } from '@insureco/seo'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<SeoHead />
</head>
<body>{children}</body>
</html>
)
}
// app/seo.routes.ts — the pages to index (Next.js cannot list routes at runtime)
import type { SeoRoute } from '@insureco/seo'
export const routes: SeoRoute[] = [
{ path: '/', seo: { kind: 'landing' } },
{ path: '/pricing', seo: { topic: 'rating software pricing' } },
{ path: '/thanks', seo: { noindex: true } },
]
// app/sitemap.ts
import { sitemap } from '@insureco/seo'
import { routes } from './seo.routes'
export const dynamic = 'force-dynamic'
export default function Sitemap() { return sitemap({ routes }) }
// app/robots.ts
import { robots } from '@insureco/seo'
export const dynamic = 'force-dynamic'
export default function Robots() { return robots({ disallow: ['/api/'] }) }
// app/[indexnow]/route.ts — serves /<INDEXNOW_KEY>.txt, 404s anything else
import { indexNowKeyRoute } from '@insureco/seo'
export const dynamic = 'force-dynamic'
export const GET = indexNowKeyRoute()
// app/seo.json/route.ts — page topics and kinds for the deploy hook (optional)
import { seoManifestRoute } from '@insureco/seo'
import { routes } from '../seo.routes'
export const dynamic = 'force-dynamic'
export const GET = seoManifestRoute({ routes })
// middleware.ts — gives the layout's <SeoHead /> the request path for the canonical
import { NextResponse, type NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const headers = new Headers(request.headers)
headers.set('x-seo-path', request.nextUrl.pathname)
return NextResponse.next({ request: { headers } })
}
export const config = { matcher: ['/((?!_next/|api/|.*\\..*).*)'] }
tawa deploy --prod. Done.Full SDK reference: the iec-seo-sdk README.
On deploy, for nextjs, static, express, hono and fastify services with spec.seo.enabled: true:
https://<domain>/sitemap.xml, registers Pieces, submits the sitemap to Search Console
and Bing, and pings IndexNow with the URLs whose lastmod changed.Sandbox and UAT register and stop. Nothing in this path can fail a deploy: a missing token or
an unreachable iec-webmaster is a [warn] line and no injection. iec-webmaster's hourly
service-sync job catches a deploy the hook missed.
The wiring ships behind the builder flag SEO_INTEGRATION_ENABLED (default off until
iec-webmaster is live). While it is off, each call the builder would make is written to the build
log with its payload (SEO (dry run, SEO_INTEGRATION_ENABLED=false): would POST …) and nothing is
sent or injected.
| Var | Example | Notes |
|---|---|---|
SEO_PROPERTY_ID | raterspot.com | The primary custom domain. Canonicals point here |
SEO_ENTITY_ID | ent_raterspot | Entity id or bare slug, from spec.seo.entity |
INDEXNOW_KEY | 3f9c… | Served at /<key>.txt so Bing can verify it |
SEO_API_URL | (optional) | Default https://iec-webmaster.tawa.pro |
These are runtime values, read server-side at request time — never NEXT_PUBLIC_*, for the
same reason as MATOMO_SITE_ID: NEXT_PUBLIC_* is inlined at docker build, before the builder
knows the values. That is also why sitemap.ts, robots.ts and the route handlers need
export const dynamic = 'force-dynamic'.
<SeoHead /> emits| Request host | Output |
|---|---|
| The custom domain | canonical to https://<SEO_PROPERTY_ID><path> + JSON-LD |
A platform host (*.tawa.pro, hypesites.tawa.pro, *.sandbox.tawa.pro, *.uat.tawa.pro, *.tawa.insureco.io, cluster-internal) | the same plus <meta name="robots" content="noindex"> |
Platform host, no SEO_PROPERTY_ID | only the noindex tag |
localhost (local dev) | nothing |
JSON-LD: Organization (Entity kind organization / brand), Product (product) or
Person (person) from GET {SEO_API_URL}/entities/{SEO_ENTITY_ID} — only an approved
Entity is published; then WebPage, or Article for piece.kind === 'article' or a piece
with datePublished; then BreadcrumbList. The Entity is cached in-process for five minutes;
the fetch times out after three seconds and never throws, so the page still renders its canonical
and robots tag when iec-webmaster is down.
The IndexNow key route matches on the request pathname, so it is mounted on a single dynamic root
segment (app/[indexnow]/route.ts); static pages always win over it. An app that already has a
root dynamic segment serves the key from middleware with indexNowKeyResponse() from
@insureco/seo/core instead.
npx seo-files --out dist # reads ./seo.json; writes sitemap.xml, seo.json, robots.txt, <key>.txt
{ "baseUrl": "https://raterspot.com",
"pages": [ { "path": "/", "topic": "insurance rating software" }, { "path": "/thanks", "noindex": true } ] }
A static service runs the builder's nginx image, and its SEO env is injected at deploy, after
the image is built — there is no start-up hook to generate files from it. So for a static site:
baseUrl in seo.json (or pass --base-url) and run seo-files in the build script;INDEXNOW_KEY is present when you build; otherwise
IndexNow for that Property falls back to Cloudflare Crawler Hints;noindex on its platform hostname.
Write canonicals into the HTML pointing at the custom domain.Any service that serves pages is wired: nextjs, static, express, hono, fastify. A
worker that declares spec.seo gets a build warning (spec.seo needs a service that serves pages) and nothing else. A server-rendered express site (policyeco-web behind insureco.io) holds
its end without the Next.js helpers: answer /sitemap.xml and /robots.txt per request host,
serve /<INDEXNOW_KEY>.txt, send X-Robots-Tag: noindex on *.tawa.pro hosts, and use
@insureco/seo/core for the JSON-LD and sitemap XML. (Until iec-webmaster#17 this was
nextjs/static only, like spec.analytics.) Unlike spec.analytics, a
malformed spec.seo block fails the build: enabled must be a boolean, entity a slug,
pages "sitemap" or a non-empty list of absolute paths, topic a short non-empty string.
catalog-version: "0.8.0". spec.seo is ignored; the service is never registered.dynamic = 'force-dynamic' on sitemap.ts / robots.ts — Next renders them at build,
when the env is empty, and ships an empty sitemap and a disallow-all robots file.export const seo inside page.tsx. Next's type check rejects unknown page exports
("seo" is not a valid Page export field). Keep page metadata in app/seo.routes.ts or a
sibling seo.ts.<SeoHead /> in a 'use client' file. The SDK is server-only; the build fails with
'server-only' cannot be imported from a Client Component module.<SeoHead /> on one page (layout and page) — two canonicals.NEXT_PUBLIC_SEO_PROPERTY_ID. Unnecessary and wrong; the value is runtime-injected.SEO_PROPERTY_ID is injected: the platform host is
noindex and the sitemap is empty. Add one with tawa domain add and verify it.Last updated: October 6, 2026