Skip to main content
Two layers over one builder:
  • 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’s generateMetadata, an Astro layout or a TanStack Start head().
  • <SEO /> from @quickbutik/kit/react and <qb-seo> from the elements: render the same payload as real tags.
The builder prefers the merchant’s 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.
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

Writes the title, description, canonical, OpenGraph, Twitter card and JSON-LD into <head> from the product it sits in. Everything it writes is tagged data-qb-seo and replaced on update.
<qb-seo> is for client-rendered pages only. Everything the elements render happens in the browser, so it only helps JavaScript-executing crawlers and share cards. For indexable catalog pages, render on the server (or prerender) and put buildSeo() output in the server-rendered <head>. Never use <qb-seo> on a page that already emits its own head.

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

  • Offer vs. AggregateOffer. A single-priced product gets an Offer; variants that differ in price get an AggregateOffer. Offers are omitted entirely when no currency is known, rather than publishing a wrong price.
  • stock: null is InStock. It means the shop does not track inventory. preorder wins 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:* use product.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 example products.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.
  • breadcrumbJsonLd positions are 1-based and contiguous. A category’s ancestors and path let buildSeo({ category }) emit a BreadcrumbList with no extra requests.

Rules

  • Never index a page that belongs to one shopper. noIndex on the cart and on /success/*; noIndex noFollow on the thank-you page.
  • noIndex filtered 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 the notFound branch, or await the lookup and call the framework’s notFound().
  • Fix baseUrl in config. Deriving it from the request’s Host header is an SEO poisoning vector. Use NEXT_PUBLIC_SITE_URL or equivalent.