← Back to Skills

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.md
Download .md

Coming soon via CLI:

tawa chaac install tawa-seo

Details

Format
Rule
Category
platform
Version
1.0.43798
Tokens
~2,370
Updated
2026-10-13
platform