SEO — spec.seo and @insureco/seo

A 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.

Quick start

  1. Declare it in 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
  1. Install the SDK and add .npmrc:
npm install @insureco/seo
# .npmrc
@insureco:registry=https://git.insureco.io/api/packages/insureco/npm/
  1. Wire it into the App Router:
// 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/|.*\\..*).*)'] }
  1. tawa deploy --prod. Done.

Full SDK reference: the iec-seo-sdk README.

What the builder does

On deploy, for nextjs, static, express, hono and fastify services with spec.seo.enabled: true:

  1. Resolves the service's verified custom domains for the environment. With none, it registers the service with iec-webmaster as "no public domain" and stops — a platform hostname is never submitted for indexing.
  2. Prod only: upserts a Property per custom domain in iec-webmaster, which verifies it with Google and Bing through DNS.
  3. Prod only: injects the runtime env below.
  4. Prod only: once the deploy is healthy, calls iec-webmaster's deploy hook. iec-webmaster reads 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.

Runtime env

VarExampleNotes
SEO_PROPERTY_IDraterspot.comThe primary custom domain. Canonicals point here
SEO_ENTITY_IDent_raterspotEntity id or bare slug, from spec.seo.entity
INDEXNOW_KEY3f9c…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'.

What <SeoHead /> emits

Request hostOutput
The custom domaincanonical 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_IDonly 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.

Static sites

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:

  • put baseUrl in seo.json (or pass --base-url) and run seo-files in the build script;
  • the IndexNow key file is written only if INDEXNOW_KEY is present when you build; otherwise IndexNow for that Property falls back to Cloudflare Crawler Hints;
  • there is no request-time host check, so the site cannot add noindex on its platform hostname. Write canonicals into the HTML pointing at the custom domain.

Which services

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.

Common mistakes

  • No catalog-version: "0.8.0". spec.seo is ignored; the service is never registered.
  • No 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.
  • Two <SeoHead /> on one page (layout and page) — two canonicals.
  • A hand-set NEXT_PUBLIC_SEO_PROPERTY_ID. Unnecessary and wrong; the value is runtime-injected.
  • No verified custom domain. No SEO_PROPERTY_ID is injected: the platform host is noindex and the sitemap is empty. Add one with tawa domain add and verify it.
  • Expecting tags locally. The env is only injected on prod deploys; local dev renders nothing.

Last updated: October 6, 2026