buildSeo(options)from@quickbutik/kit: pure and framework-free. Produces titles, canonical URLs, OpenGraph, Twitter cards and structured data from a product, a category or the shop. Use it in Next’sgenerateMetadata, an Astro layout or a TanStack Starthead().<SEO />from@quickbutik/kit/reactand<qb-seo>from the elements: render the same payload as real tags.
seoTitle and seoDescription over the product name and description.
React
<SEO /> reads the product and its currency from <ProductProvider> and builds the canonical from productPath. An explicit prop always wins; an undefined prop never blanks the context. useSeo(input) returns the resolved tags without rendering.
Where the tags land
React 19 hoists<title>, <meta> and <link> into <head> from anywhere in the tree, so <SEO /> can sit inside the page. React 18 does not: there the tags render inline and <title> does not work, so use useSeo() with your framework’s head mechanism. JSON-LD is never hoisted and does not need to be: Google reads it anywhere in the document.
Next.js App Router
Either route works. Pick one owner per page.- <SEO /> only
- generateMetadata
Render
<SEO /> in the page and do not export metadata from a layout. A layout metadata export does not get overridden by <SEO />, it competes with it, and the page ships two <title> elements. If you must keep the export, pass skipTitle to <SEO />.Web components
<head> from the product it sits in. Everything it writes is tagged data-qb-seo and replaced on update.
Other stacks
SeoTags is a plain object (title, description, canonical, robots, openGraph, twitter, jsonLd[], meta[]) rather than markup, because Next wants Metadata, Astro wants tags in its layout and React 19 can render them anywhere. For a sitemap or static params, read the catalog with products.listAll() at build time.
SeoDefaults
SeoInput adds the per-page half: product, category, breadcrumbs, title, description, url, images, noIndex, noFollow, extra jsonLd and extra meta. Paths are templates rather than functions because these defaults cross a client boundary.
Structured data
Offervs.AggregateOffer. A single-priced product gets anOffer; variants that differ in price get anAggregateOffer. Offers are omitted entirely when no currency is known, rather than publishing a wrong price.stock: nullisInStock. It means the shop does not track inventory.preorderwins when set.- Prices use the currency’s real decimals and are emitted as strings, so JPY is not reported as a hundredth of its price.
- Publish the price you charge. Offers and
og:price:*useproduct.currency. A product read in a"display"currency is shown converted but charged in the shop’s currency, so its structured data would publish a price no order is charged. Read the product for SEO in the shop’s own currency, for exampleproducts.getBySlug(slug, { currency: null })on the server, and keep the shopper’s currency for the visible page. - Every
<is escaped as<, so a product name containing</script>cannot break out of the tag. breadcrumbJsonLdpositions are 1-based and contiguous. A category’sancestorsandpathletbuildSeo({ category })emit aBreadcrumbListwith no extra requests.
Rules
- Never index a page that belongs to one shopper.
noIndexon the cart and on/success/*;noIndex noFollowon the thank-you page. noIndexfiltered and paginated listings and search results, so filter combinations do not compete with the canonical catalog.- Soft 404s. A product lookup inside
<Suspense>has already streamed a 200 when it turns out to be missing. Render<SEO noIndex />in thenotFoundbranch, or await the lookup and call the framework’snotFound(). - Fix
baseUrlin config. Deriving it from the request’sHostheader is an SEO poisoning vector. UseNEXT_PUBLIC_SITE_URLor equivalent.