SEO
The platform already produces SEO for every page: title, description,
canonical, noIndex, Open Graph, Twitter card, keywords, JSON-LD and hreflang
all ride on the marketing page bundle. @businessdash/sdk/seo-core turns that
into whatever your framework puts in <head>.
Before it, the SDK handed you the object as a type and stopped. Eleven
fields is enough that everyone maps four of them — and the two most often
skipped are noIndex and canonical, which are exactly the two where being
wrong costs something and says nothing.
Layout and page compose
import { resolveSeo, platformSeoToInput, toNextMetadata } from '@businessdash/sdk/seo-core'
// layout.tsx — the defaults every page inherits
const layoutSeo = {
baseUrl: 'https://acme.test',
siteName: 'Acme Plumbing',
titleSuffix: ' | Acme Plumbing',
ogImage: '/og/default.png',
}
// page.tsx
export async function generateMetadata() {
const bundle = await client.marketing.pageBundle('services')
return toNextMetadata(
resolveSeo(layoutSeo, platformSeoToInput(bundle.seo), { canonical: '/services' }),
)
}
Precedence, lowest to highest: layout defaults → the platform's page SEO → overrides in code. Code wins because a developer has context the CMS does not — a search page that must never be indexed, a canonical pointing at a parent.
An omitted field inherits; an explicit null clears. That distinction
is why this isn't Object.assign: a page writing canonical: null is making a
decision, and treating it like an omission would ignore it.
JSON-LD is the one field that accumulates rather than replaces. A layout's
Organization node and a page's Product node both belong in the document.
Every framework
| Framework | Adapter | Where it goes |
|---|---|---|
| Next.js | toNextMetadata | return from generateMetadata |
| Remix / RR7 | toRemixMeta | return from meta |
| Nuxt | toNuxtHead | pass to useHead() |
| Qwik City | toQwikDocumentHead | the route's head |
| TanStack Start | toTanStackHead | the route's head() |
| Astro, Svelte, Angular, HTMX | toHeadTags | loop into markup |
| Laravel, Phoenix, plain Node | renderHeadTags | echo the string |
| Vanilla / client routing | applyToDocument | mutates <head> |
No adapter imports a framework — they return plain objects shaped the way each one expects, so the module works everywhere and adds a dependency nowhere.
Next has no metadata slot for JSON-LD, so toNextMetadata omits it — render
it in the page body instead. Remix, Nuxt and TanStack all carry it natively.
Two rules worth knowing
A relative canonical is dropped, not emitted. Crawlers resolve it against
whatever URL they fetched, so on a paginated or parameterised URL it points
somewhere nobody intended. Set baseUrl and it resolves; leave it unset and a
relative canonical is omitted rather than made wrong. A missing canonical is
recoverable — a wrong one consolidates ranking onto the wrong page.
robots is always emitted, both ways. Absence means "index" to a crawler,
so relying on absence to express noindex is the failure that publishes a page
meant to be hidden.
Local SEO — service areas × services
The pages that win "plumber in Ashford".
import { servicesTimesAreas } from '@businessdash/sdk/seo-core'
const plans = servicesTimesAreas({
siteUrl: 'https://acme.test',
services: [{ slug: 'drains', name: 'Drain cleaning', description: '…' }],
areas: [{ slug: 'ashford', name: 'Ashford', region: 'Kent' }],
path: (s, a) => `/services/${s.slug}/${a.slug}`,
include: (s, a) => coverage.has(`${s.slug}:${a.slug}`),
describe: ({ service, area }) => ({
description: `Same-day ${service.name.toLowerCase()} across ${area.name}.`,
}),
})
Each plan carries a path and a ready SeoInput. Feed them to Next's
generateStaticParams, Astro's getStaticPaths, a SvelteKit entries export,
or a build script — which router builds the pages is your business.
Each generated page gets its own canonical and a Service node scoped with
areaServed to that place. Without those two, near-identical pages get
deduplicated away and the Service node is indistinguishable from the generic
services page.
describe is a callback rather than a template on purpose. Pages differing
only by a swapped town name are what "doorway pages" means, and they are
penalised rather than ranked. Write something true about serving that place,
or don't generate the page — and use include to skip combinations you don't
actually cover, so you don't rank for work you have to turn down.
Product pages
import { productPageSeo } from '@businessdash/sdk/seo-core'
const seo = productPageSeo({
siteUrl: 'https://acme.test',
path: `/shop/${product.slug}`,
product: { name: product.name, priceCents: product.priceCents, currency: 'USD' },
})
Emits Product + Offer + BreadcrumbList, money as integer cents like
everywhere else in this platform. Pairs with the AI Distribution product feed:
the feed gets the product into the engines' catalogues, this markup is what
their crawlers verify on the landing page. One without the other is half the
job.
Blog posts
import { blogPostSeo, blogListingSeo } from '@businessdash/sdk/seo-core'
const seo = resolveSeo(layoutDefaults, blogPostSeo({ siteUrl, post }))
Emits BlogPosting with the publish and modified dates, the byline, the tags,
a BreadcrumbList, and og:type: article plus the article:* meta that make
a shared link render as a dated, attributed card rather than a bare URL.
Gated posts stay indexed — and say so
A subscriber-only post should still be findable; discoverability is how anyone subscribes. But serving the full article to a crawler and a paywall to a reader is cloaking, which is penalised.
So a gated post gets schema.org's paywall markup — isAccessibleForFree: false
plus a hasPart naming the gated region — which declares the difference
instead of hiding it. Anything that is not public counts as gated, including
an access level this SDK does not recognise: failing the other way would
declare open access for content that is actually restricted.
Point paywallSelector at the element your template actually hides. A selector
matching nothing makes the markup a claim you are not backing up.
Archive pages past the first are noindex — the posts are what should rank,
and a thin page 7 competes with them for the same terms.
Storefront listings
import { productListingSeo, transactionalPageSeo } from '@businessdash/sdk/seo-core'
const seo = resolveSeo(layoutDefaults, productListingSeo({
siteUrl, path: '/shop/tools', title: 'Tools', products,
}))
CollectionPage + ItemList, so a crawler sees what the category contains
without fetching every product.
Filtered views canonicalise to the unfiltered category. Faceted URLs —
colour × size × sort — multiply into thousands of near-identical pages that eat
crawl budget and split ranking. Pass filtered and unfilteredPath and they
consolidate instead of competing.
transactionalPageSeo() marks cart, checkout, account and order-confirmation
pages noindex. A cart URL in search results is useless at best; order
confirmations have been indexed with customer details in the query string by
more than one large retailer.
Rich results on a product page
productPageSeo adds two things that change how a result looks:
aggregateRating— only whenreviewCount > 0. AnAggregateRatingwith zero reviews is invalid, and Google rejects the whole Product node for it, so a product with no reviews yet would lose its price and availability too.AggregateOfferwhenpriceRangeCentsspans variants. A single price on a product whose variants differ is wrong in one direction or the other.
Non-JavaScript starters
All six carry the same surface, reading the bundle's seo and rendering head
tags with the same rules — Seo::render() on Laravel, Seo.render/2 on
Phoenix, Seo.render on Swift and Vapor, Seo.render on Kotlin,
renderSeoHead on Dart.
robots.txt and sitemap.xml
Both are generated, not hand-maintained. Every starter already ships the routes; this explains what they do and what you should change.
robots.txt — fully automatic
Proxied straight from the platform. Three things it does that a hand-written file cannot:
- Points at your sitemap on your own verified hostname.
- Disallows every token-gated surface —
/portal,/quotes/accept,/contracts/sign,/invoices/pay. A crawler following one of those lands on a login redirect, which reads as a soft 404 and spends crawl budget that will never return content. - Switches to
Disallow: /while billing is suspended, so a lapse reads as a temporary outage rather than a dead site and your rankings survive it.
You should not need to touch it.
sitemap.xml — three sources, one file
The platform cannot build this alone. It knows the org has 40 blog posts; it
does not know whether your site renders them at /blog/[slug],
/updates/[slug], or not at all. Guessing produces a sitemap full of 404s,
which is worse than a smaller one — a crawler that finds broken URLs discounts
every URL in the file.
So @businessdash/sdk/sitemap merges:
- Your own pages — declared by hand in the starter's
STATIC_PATHS. - What the platform owns the shape of — published site-builder pages, published legal documents, materialised programmatic pages.
- Content mapped onto your routes — via the
routesoption.
const xml = await buildSitemap({
client,
siteId,
baseUrl: 'https://acme.com',
staticPaths: ['/', '/about', '/contact'],
routes: {
blog: '/updates', // → /updates/{slug}
product: '/store', // → /store/{id}
},
})
Two things to keep updated
STATIC_PATHS — add a page to your repo, add it here. Nothing else knows
it exists. Always include /; a sitemap missing the homepage is a common and
entirely self-inflicted omission.
routes — where your app renders platform content. Move your blog to
/news, change the prefix here.
routes is opt-in, and omission is the safe default. Delete a line and
those URLs vanish from the sitemap — which is correct for a business that
doesn't have that surface. A mobile mechanic with no storefront should not
advertise /store/{id} URLs that 404.
Two independent gates enforce this: the entitlement check (no
ecommerce.storefront, no product data) and your routes declaration. Both
default to "no".
What it never emits
The customer portal and every other token-gated path, filtered on both sides —
the platform excludes them from what it returns, and the SDK filters again in
case a staticPaths entry names one by hand.
When something is skipped
Pass onSkip to find out why. Worth logging during setup:
onSkip: (section, reason) =>
console.info(`[sitemap] skipped ${section}: ${reason}`)
// → [sitemap] skipped products: org has no ecommerce.storefront entitlement
// → [sitemap] skipped blog: no routes.blog declared
That line is the difference between a deliberate omission and one you discover months later in Search Console.
It never fails loudly
Sections fail independently, and an unreachable platform yields a sitemap
containing just your homepage — never an empty <urlset>, which would
affirmatively tell crawlers your site has no pages. A crawler retries a thin
sitemap and backs off from an error page.