SEO: @sonordev/site-kit/seo
Server Components and server helpers that render what you manage in the SEO module at 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
# .env.local
SONOR_API_KEY=sonor_xxxxxxxx_xxxxxThat'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, 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)
// 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).
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:
const metadata = await getManagedMetadata({ path: '/about' })
return typeof metadata.title === 'string'
? { ...metadata, title: { absolute: metadata.title } }
: metadatawithManagedMetadata(path, pageMetadata?)
Builds the generateMetadata function for you. Pick one of these forms:
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.
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>
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
BreadcrumbListbuilt from the path, when there isn't one already and the project has a site URL (skipped on/) - a speakable
WebPageorArticlenode, whenspeakable,pageNameandpageUrlare 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. |
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 aBreadcrumbList.labelsmaps a path segment to its display name.createWebSiteOrganizationStub({ name, url, sameAs?, knowsAbout? })returns a minimalOrganizationandWebSitepair with stable@ids. 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>
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. |
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:
<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>
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).
Content blocks: <ManagedContent> (deprecated)
Deprecated in 7.1 and removed in 8.0. Page copy is managed 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:
<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/proxyapplies Sonor-managed redirects by default. See the redirects README for the standalone helpers. - Sitemap:
createSitemap()from@sonordev/site-kit/sitemapinapp/sitemap.ts. See the sitemap README.
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:
{
"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,502and503responses 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:
// 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/ManagedNoScriptswere retired in June 2026. They render nothing and make no request. Load third-party scripts withnext/scriptin your own code.LocationPageContent/getLocationSectionwere removed in 7.0. They were the one place that still sent aprojectIdin the request body instead of authenticating with the key, and no site used them.projectIdon any option or prop is ignored. The project comes from the key.
Upgrading older code
- Drop
projectIdfrom every call and prop. The fetchers take just the path:getSEOPageData('/about'). SONOR_API_KEYis the only variable.UPTRADE_API_KEYandNEXT_PUBLIC_UPTRADE_*aren't read,uptrade_keys aren't accepted, andSONOR_PROJECT_IDisn't needed.npx sonor-setup codemod --only uptrade-to-sonor --writemoves a pre-rebrand site over.- Import from
@sonordev/site-kit/seoor@sonordev/site-kit/seo/server. There's no/seo/apientry.
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.