docs
    site-kit: SEO
    v7.2.0.md

    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_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

    ImportRuns onContains
    @sonordev/site-kit/seoServer onlyEverything on this page except registerLocalSitemap
    @sonordev/site-kit/seo/serverServer onlyThe data fetchers, getManagedMetadata, getManagedMetadataWithAB, generateSitemap, registerLocalSitemap, and the types
    @sonordev/site-kit/seo/clientClientSitemapSync 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.',
        },
      })
    }
    OptionTypeNotes
    pathstringRequired. The page path as Sonor has it.
    fallbackMetadataFills any field Sonor has no managed value for. Used in full when the page isn't in Sonor at all.
    overridesPartial<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 fieldMetadata field
    managed_titletitle
    managed_meta_descriptiondescription
    managed_keywordskeywords
    managed_robotsrobots
    managed_canonicalalternates.canonical
    language_alternatesalternates.languages
    managed_og_title, managed_og_description, managed_og_imageopenGraph 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 } }
      : metadata

    withManagedMetadata(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 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.

    PropDefaultNotes
    pathRequired.
    includeTypes / excludeTypesType 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.
    speakabletrue 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, pageUrlRequired for the speakable node.
    includeEntityGraphtrueMeant 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 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 @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" />
    PropDefaultNotes
    pathRequired.
    showTitletrueRenders the FAQ's title as an <h2>.
    includeSchematrueEmits 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.
    siteSub-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.

    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.

    positionMarkup
    '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/proxy applies Sonor-managed redirects by default. See the redirects README for the standalone helpers.
    • Sitemap: createSitemap() from @sonordev/site-kit/sitemap in app/sitemap.ts. See the sitemap README.

    The SEO module keeps a few lower-level helpers:

    FunctionReturns
    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().

    FunctionReturns
    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:

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