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…
Ruleplatform
About
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.
Skill Content
This is the raw markdown that gets installed as a Claude Code rule.
# 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`):
```yaml
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
```
2. Install the SDK and add `.npmrc`:
```bash
npm install @insureco/seo
```
```
# .npmrc
@insureco:registry=https://git.insureco.io/api/packages/insureco/npm/
```
3. Wire it into the App Router:
```tsx
// 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>
)
}
```
```ts
// 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 } },
]
```
```ts
// 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 })
```
```ts
// 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/|.*\\..*).*)'] }
```
4. `tawa deploy --prod`. Done.
Full SDK reference: the [iec-seo-sdk README](https://git.insureco.io/insureco/iec-seo-sdk).
## 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
| 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'`.
## What `<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.
## Static sites
```bash
npx seo-files --out dist # reads ./seo.json; writes sitemap.xml, seo.json, robots.txt, <key>.txt
```
```json
{ "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.
Install
Copy the skill content and save it to:
~/.claude/rules/tawa-seo.mdComing soon via CLI:
tawa chaac install tawa-seoDetails
- Format
- Rule
- Category
- platform
- Version
- 1.0.43798
- Tokens
- ~2,370
- Updated
- 2026-10-13
platform