# SEO: `@sonordev/site-kit/seo`

Server Components and server helpers that render what you manage in the SEO module at [app.sonor.io](https://app.sonor.io): page metadata, JSON-LD, FAQs, internal links, content blocks, redirects and robots directives.

The project comes from `SONOR_API_KEY`. Nothing in this module takes a project ID. A few option types and props still carry an optional `projectId` from older versions; it's ignored, so leave it out.

## Setup

```bash
# .env.local
SONOR_API_KEY=sonor_xxxxxxxx_xxxxx
```

That's the only variable you need. Keep it server-side, with no `NEXT_PUBLIC_` prefix. `SONOR_API_URL` is optional and defaults to `https://api.sonor.io`.

If the key is missing, the server helpers throw:

```
@sonordev/seo: SONOR_API_KEY environment variable is required for server-side SEO functions
```

## Entry points

| Import                          | Runs on     | Contains                                                                                                                                        |
| ------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `@sonordev/site-kit/seo`        | Server only | Everything on this page except `registerLocalSitemap`                                                                                           |
| `@sonordev/site-kit/seo/server` | Server only | The [data fetchers](#data-fetchers), `getManagedMetadata`, `getManagedMetadataWithAB`, `generateSitemap`, `registerLocalSitemap`, and the types |
| `@sonordev/site-kit/seo/client` | Client      | `SitemapSync` only                                                                                                                              |

Both server entries import `server-only`, so importing either one from a Client Component fails the build. That's deliberate: it keeps the key out of the browser bundle.

## Page metadata

### `getManagedMetadata(options)`

```tsx
// app/services/[slug]/page.tsx
import { getManagedMetadata } from '@sonordev/site-kit/seo'

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  return getManagedMetadata({
    path: `/services/${slug}`,
    fallback: {
      title: 'Our Services',
      description: 'What we do and where we do it.',
    },
  })
}
```

| Option      | Type                        | Notes                                                                                                                                                                                             |
| ----------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `path`      | `string`                    | Required. The page path as Sonor has it.                                                                                                                                                          |
| `fallback`  | `Metadata`                  | Fills any field Sonor has no managed value for. Used in full when the page isn't in Sonor at all.                                                                                                 |
| `overrides` | `Partial<Metadata>`         | Applied last, so it wins over managed values.                                                                                                                                                     |
| `favicon`   | `'metadata' \| 'component'` | `'metadata'` (default) adds `icons` from the project logo. Pass `'component'` when your layout already renders the favicon, which `SiteKitLayout` does by default, so icons aren't emitted twice. |

It returns a Next.js `Metadata` object with two extra flags, `_managed` and `_source`. Managed fields map like this:

| Sonor field                                                      | Metadata field                                                                               |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `managed_title`                                                  | `title`                                                                                      |
| `managed_meta_description`                                       | `description`                                                                                |
| `managed_keywords`                                               | `keywords`                                                                                   |
| `managed_robots`                                                 | `robots`                                                                                     |
| `managed_canonical`                                              | `alternates.canonical`                                                                       |
| `language_alternates`                                            | `alternates.languages`                                                                       |
| `managed_og_title`, `managed_og_description`, `managed_og_image` | `openGraph` and `twitter` (`summary_large_image`), falling back to the title and description |

When the page exists in Sonor but has neither a title nor a description, the call asks Signal to write them in the background and returns your fallback for now. The generated copy shows up once the cached response refreshes (see [Caching](#caching)).

**Title templates.** The managed title comes back as a plain string, so a root-layout `title.template` still applies to it. If your managed titles already include the brand, you'll get it twice. Mark the title absolute:

```ts
const metadata = await getManagedMetadata({ path: '/about' })
return typeof metadata.title === 'string'
  ? { ...metadata, title: { absolute: metadata.title } }
  : metadata
```

### `withManagedMetadata(path, pageMetadata?)`

Builds the `generateMetadata` function for you. Pick one of these forms:

```ts
import { withManagedMetadata } from '@sonordev/site-kit/seo'

// A fixed path
export const generateMetadata = withManagedMetadata('/about')

// A path built from params
export const generateMetadata = withManagedMetadata(
  async ({ params }) => `/services/${(await params).slug}`,
)

// Page-level values on top of Sonor's
export const generateMetadata = withManagedMetadata('/about', async () => ({
  title: 'About Us',
}))
```

Whatever `pageMetadata` returns wins over Sonor's values. `openGraph` and `twitter` merge one level deep. It calls `getManagedMetadata` with the default `favicon: 'metadata'`.

### A/B-tested titles and descriptions

`getManagedMetadataWithAB` works like `getManagedMetadata`, then swaps in the assigned variant of any running title or description test for that path. `getABVariant({ path, field, sessionId? })` does the same for one field (`'title' | 'description' | 'content'`) and returns `{ testId, variant, value }`, or `null` when nothing's running.

```ts
import { cookies } from 'next/headers'
import { getManagedMetadataWithAB } from '@sonordev/site-kit/seo'

export async function generateMetadata() {
  const sessionId = (await cookies()).get('visitor_id')?.value
  return getManagedMetadataWithAB({ path: '/pricing', sessionId })
}
```

Pass a stable visitor ID your site already keeps. The kit doesn't set a cookie for this, and without one every request gets a random variant. Reading cookies makes the route dynamic, and each variant lookup records an impression.

## JSON-LD

### `<ManagedSchema>`

```tsx
import { ManagedSchema } from '@sonordev/site-kit/seo'

export default function Page() {
  return (
    <>
      <ManagedSchema path="/services/plumbing" />
      <main>{/* ... */}</main>
    </>
  )
}
```

It renders one `application/ld+json` script (an `@graph` when there's more than one node) that combines:

- the schema Sonor has for the path, filtered by `includeTypes` / `excludeTypes`
- the page's Signal-generated `managed_schema`
- anything you pass in `additionalSchemas`
- a `BreadcrumbList` built from the path, when there isn't one already and the project has a site URL (skipped on `/`)
- a speakable `WebPage` or `Article` node, when `speakable`, `pageName` and `pageUrl` are all set

Everything Sonor supplies (its schema rows, `managed_schema` and the entity graph) loses any template placeholder first. A node whose values are a template's unfilled slots is dropped: a URL on a reserved example domain (`example.com`), a name like "Example" or "Your Business Name", a placeholder phone such as `+1-000-000-0000`, a slot like `[Resident Name]` or `{plan.name}`, or an object that's only a note about what goes there. Its real siblings and parents stay, so an `FAQPage` keeps its questions when only its publisher was a placeholder, and the `BreadcrumbList` fallback still applies when a placeholder breadcrumb is dropped. Your `additionalSchemas` are never touched. The rule is `@sonordev/contracts/schema-placeholders`.

| Prop                            | Default     | Notes                                                                                                                                                                                                                                                                                                                                            |
| ------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `path`                          |             | Required.                                                                                                                                                                                                                                                                                                                                        |
| `includeTypes` / `excludeTypes` |             | Type allow and deny lists. `includeTypes` keeps Sonor schema rows by `schema_type`. `excludeTypes` drops a node of that `@type` wherever it sits in Sonor's schema: a whole row, an `@graph` member, or a nested value like `mainEntity`, and in `managed_schema` too. A row left empty is dropped. Your `additionalSchemas` are never filtered. |
| `additionalSchemas`             | `[]`        | Extra nodes to merge in.                                                                                                                                                                                                                                                                                                                         |
| `speakable`                     |             | `true` for the default selectors (`h1`, `[data-speakable="true"]`, `.page-summary`, `.key-points`, `.aeo-block[data-speakable="true"]`), or `{ cssSelector }` / `{ xpath }`.                                                                                                                                                                     |
| `pageType`                      | `'WebPage'` | `'WebPage'` or `'Article'`, for the speakable node.                                                                                                                                                                                                                                                                                              |
| `pageName`, `pageUrl`           |             | Required for the speakable node.                                                                                                                                                                                                                                                                                                                 |
| `includeEntityGraph`            | `true`      | Meant to add nodes from Signal's entity graph. It adds nothing today; see [Entity graph](#entity-graph-and-ai-visibility).                                                                                                                                                                                                                       |

It's wrapped in `Suspense`, so the fetch never holds up the rest of the page. The script streams in when it's ready.

### `<LLMSchema path>`

Renders the page's `managed_llm_schema` as a `WebPage` JSON-LD script marked `data-llm-optimized="true"`, linked to the site's `WebSite` node when the project has a site URL. It renders nothing when the page has no LLM schema, or when that schema is a template placeholder (see `<ManagedSchema>`).

### Schema helpers

- `createSchema(type, data)` returns `{ '@context': 'https://schema.org', '@type': type, ...data }`.
- `createBreadcrumbSchema(baseUrl, path, labels?)` builds a `BreadcrumbList`. `labels` maps a path segment to its display name.
- `createWebSiteOrganizationStub({ name, url, sameAs?, knowsAbout? })` returns a minimal `Organization` and `WebSite` pair with stable `@id`s. Only reach for it when Sonor isn't already emitting those nodes.

Hand the result to `ManagedSchema` through `additionalSchemas`, so it's serialized and escaped in the same script as everything else.

## FAQs: `<ManagedFAQ>`

```tsx
import { ManagedFAQ } from '@sonordev/site-kit/seo'

<ManagedFAQ path="/services/plumbing" />
```

| Prop            | Default    | Notes                                                                                   |
| --------------- | ---------- | --------------------------------------------------------------------------------------- |
| `path`          |            | Required.                                                                               |
| `showTitle`     | `true`     | Renders the FAQ's title as an `<h2>`.                                                   |
| `includeSchema` | `true`     | Emits `FAQPage` JSON-LD, but only when the FAQ is also set to include schema in Sonor.  |
| `renderItem`    |            | `(item, index) => ReactNode`, for your own markup.                                      |
| `className`     | `'sk-faq'` | Wrapper class.                                                                          |
| `site`          |            | Sub-site host on a multi-site project. See [Multi-site projects](#multi-site-projects). |

The default markup is native `<details>` / `<summary>` with its own small `<style>` block (`sk-faq-*` classes), so there's no CSS to import. Only visible items render, in their saved order. Answers are HTML, so render them as HTML in a custom item:

```tsx
<ManagedFAQ
  path="/faq"
  renderItem={(faq) => (
    <details key={faq.id}>
      <summary>{faq.question}</summary>
      <div dangerouslySetInnerHTML={{ __html: faq.answer }} />
    </details>
  )}
/>
```

Don't also hand-write `FAQPage` JSON-LD for a page that renders `ManagedFAQ`. You'd ship it twice.

## Internal links: `<ManagedInternalLinks>`

```tsx
import { ManagedInternalLinks } from '@sonordev/site-kit/seo'

<ManagedInternalLinks path="/article/my-post" position="related" limit={5} />
```

Renders the internal links Sonor has for `path` at that position, or nothing when there aren't any.

| `position`           | Markup                                                            |
| -------------------- | ----------------------------------------------------------------- |
| `'bottom'` (default) | "Related Articles" list                                           |
| `'sidebar'`          | `<aside>` with a "Related Pages" list                             |
| `'related'`          | `<nav>` grid titled "You May Also Like", with each link's context |
| `'inline'`           | Bare links in a `<span>`, for dropping into copy                  |

`limit` defaults to 5. `renderLink(link)` replaces the default `<a>`, and `className` replaces the default `sk-internal-links sk-internal-links--{position}`. `site` pins the sub-site host (see [Multi-site projects](#multi-site-projects)).

## Content blocks: `<ManagedContent>` (deprecated)

Deprecated in 7.1 and removed in 8.0. Page copy is [managed copy](https://sonor.dev/site-kit/copy) now: wrap the text in `<ManagedSlot>` or `<ManagedRichText>` and edit it in Sonor under Website → Content, with drafts, history and Edit on page. Sonor no longer creates content blocks, so `ManagedContent` renders its `fallback`.

## Multi-site projects

One Sonor project can serve many domains (example.com plus its city microsites). Managed FAQs and internal links can be project-wide (every host) or tagged with one host (that host only). `ManagedFAQ` and `ManagedInternalLinks` send the site host with every read, so each microsite gets its own rows plus the project-wide ones, never a sibling's.

The host resolves from `NEXT_PUBLIC_SITE_URL`, which every microsite already sets, so most sites change nothing. To pin one, pass `site`:

```tsx
<ManagedFAQ path="/contact" site="charlotte.example.com" />
await getFAQData('/contact', 'charlotte.example.com')
await getInternalLinks('/contact', { position: 'bottom', site: 'charlotte.example.com' })
await getContentBlock('/contact', 'hero', 'charlotte.example.com')
```

When no host resolves, `site` is left off and the API answers for the project's primary domain. Single-site projects and older API servers ignore it.

## Redirects, robots and sitemaps

These have dedicated modules, and that's where to start:

- **Redirects:** `createProxy()` from `@sonordev/site-kit/proxy` applies Sonor-managed redirects by default. See the [redirects README](https://sonor.dev/site-kit/redirects) for the standalone helpers.
- **Sitemap:** `createSitemap()` from `@sonordev/site-kit/sitemap` in `app/sitemap.ts`. See the [sitemap README](https://sonor.dev/site-kit/sitemap).

The SEO module keeps a few lower-level helpers:

| Function                                       | Returns                                                                                                                                                                                                                                                       |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `getRedirect({ path })`                        | `{ destination, statusCode, isExternal }`, or `null`. Expired rules are skipped.                                                                                                                                                                              |
| `getRobotsDirective({ path })`                 | `{ index, follow, noarchive?, nosnippet?, ... }` parsed from the page's managed robots value. `{ index: true, follow: true }` when there isn't one.                                                                                                           |
| `isIndexable(projectId, path)`                 | `boolean`. This is a legacy signature and the first argument is ignored. `(await getRobotsDirective({ path })).index` says the same thing.                                                                                                                    |
| `generateSitemap({ baseUrl, publishedOnly? })` | Sonor's page list as `{ path, url, lastmod, changefreq, priority }`. `publishedOnly` defaults to `true`. Those keys aren't Next's `MetadataRoute.Sitemap` shape (`lastModified`, `changeFrequency`), so map them before returning them from `app/sitemap.ts`. |

### Registering pages with Sonor

`createSitemap` already syncs your page list to Sonor during `next build`. A site without an `app/sitemap` route can use the postbuild CLI instead:

```json
{
  "scripts": {
    "postbuild": "sonor-register-sitemap --auto-discover"
  }
}
```

It skips itself when an `app/sitemap` route exists, and it only adds or updates pages unless you pass `--full-replace`.

On a multi-site project, each page is tagged with the host it belongs to. The CLI takes it from `NEXT_PUBLIC_SITE_URL` (it loads `.env` and `.env.local`), or from `--site ohio.example.com`. It used to send no host at all, so a microsite's pages synced as unattributed.

From code, `registerLocalSitemap({ entries?, autoDiscover?, mode?, site? })` on `@sonordev/site-kit/seo/server` does the same and is additive by default.

`registerSitemap(entries, { mode?, site? })` is the raw call, and it **defaults to `'full-replace'`**, which prunes every page that isn't in `entries`. Pass `mode: 'additive'` unless `entries` really is the whole site. It sends the site host the same way.

All of these and createSitemap's own sync build the request in one place (`seo/register-sitemap-request.ts`).

## Data fetchers

Every component above is built on these. They're server-only, take paths rather than project IDs, and are deduplicated per request with React `cache()`.

| Function                                                   | Returns                                                                                                                                                     |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `getSEOPageData(path)`                                     | `{ page, project }`. `page` is the page's Sonor row (the `managed_*` fields) or `null`; `project` is `{ id, title, domain, logo_url, site_url }` or `null`. |
| `getSchemaMarkups(path, { includeTypes?, excludeTypes? })` | Schema rows (`schema_type`, `schema_json`, ...). `excludeTypes` also prunes matching nodes inside each row.                                                 |
| `getFAQData(path, site?)`                                  | The FAQ (`title`, `description`, `items`, `include_schema`), or `null`                                                                                      |
| `getInternalLinks(path, { position?, limit?, site? })`     | Link rows                                                                                                                                                   |
| `getContentBlock(path, section, site?)`                    | The content block, or `null`                                                                                                                                |
| `getABTest(path, field)`                                   | The running test for that field, or `null`                                                                                                                  |
| `recordABImpression(testId, variant, sessionId?)`          | `void`                                                                                                                                                      |
| `getRedirectData(path)`                                    | The raw redirect row, or `null`                                                                                                                             |
| `getRobotsData(path)`                                      | The page's managed robots string, or `null`                                                                                                                 |
| `getSitemapEntries({ publishedOnly? })`                    | Raw sitemap rows                                                                                                                                            |
| `getManagedScripts(position, path?)`                       | Always `[]` (retired, see below)                                                                                                                            |

`getSEOPageData` and `getSchemaMarkups` share one request per path, so using both in a render (metadata plus `ManagedSchema`) costs a single round trip.

## Entity graph and AI visibility

`getEntities`, `getPrimaryEntity`, `getEntityEnhancedSchema`, `getVisibilityScore` and `getVisibilitySummary` are exported, but called from a site they currently return empty results (`[]` or `null`): the Signal endpoints behind them don't accept a site key yet. That's also why `ManagedSchema`'s `includeEntityGraph` has no effect. Don't build on them until that changes.

## Caching

- **Within a request:** React `cache()` collapses identical calls into one.
- **Across requests:** Sonor API responses sit in Next's data cache for 24 hours (entity-graph calls, 5 minutes). Transient `429`, `502` and `503` responses are retried with backoff, inside a 30-second budget per call.

So a change in the dashboard can take up to a day to reach the site. To push one sooner, revalidate the path from a route you control:

```ts
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'

export async function POST(request: Request) {
  if (request.headers.get('x-revalidate-secret') !== process.env.REVALIDATION_SECRET) {
    return new Response('Unauthorized', { status: 401 })
  }
  const { path } = await request.json()
  revalidatePath(path)
  return Response.json({ revalidated: true })
}
```

## Retired and deprecated

- **`ManagedScripts` / `ManagedNoScripts`** were retired in June 2026. They render nothing and make no request. Load third-party scripts with `next/script` in your own code.
- **`LocationPageContent` / `getLocationSection`** were removed in 7.0. They were the one place that still sent a `projectId` in the request body instead of authenticating with the key, and no site used them.
- **`projectId`** on any option or prop is ignored. The project comes from the key.

## Upgrading older code

- Drop `projectId` from every call and prop. The fetchers take just the path: `getSEOPageData('/about')`.
- `SONOR_API_KEY` is the only variable. `UPTRADE_API_KEY` and `NEXT_PUBLIC_UPTRADE_*` aren't read, `uptrade_` keys aren't accepted, and `SONOR_PROJECT_ID` isn't needed. `npx sonor-setup codemod --only uptrade-to-sonor --write` moves a pre-rebrand site over.
- Import from `@sonordev/site-kit/seo` or `@sonordev/site-kit/seo/server`. There's no `/seo/api` entry.

## Troubleshooting

**`SONOR_API_KEY environment variable is required`.** The key isn't in the server environment. Check `.env.local` locally and the host's environment settings in production, then redeploy.

**A build error that mentions `server-only`.** A Client Component imports `@sonordev/site-kit/seo` or `/seo/server`. Move that code into a Server Component, or import `SitemapSync` from `/seo/client`.

**The metadata is always the fallback.** The path isn't in Sonor yet, or its managed fields are empty. Make sure the page is registered (`createSitemap` or `sonor-register-sitemap`) and that `path` matches the path Sonor has.

**The schema isn't in the page.** `ManagedSchema` has to render inside a Server Component. It streams in after the first bytes, so check the complete HTML (`curl` the page) rather than an early paint.

**The brand is in the title twice.** See [Title templates](#getmanagedmetadataoptions).
