> ## Documentation Index
> Fetch the complete documentation index at: https://quickbutik.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# SEO and structured data

> Titles, canonicals, OpenGraph and schema.org JSON-LD for products, categories and the shop.

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

```tsx theme={null}
// Near the root: the values every page would otherwise repeat
<SeoProvider
  baseUrl="https://minbutik.se"
  shop={shop}
  currency="SEK"
  productPath="/products/{slug}"
  organization
>
  {children}
</SeoProvider>
```

```tsx theme={null}
// A product page inside <ProductProvider> needs nothing:
<ProductProvider slug={slug}>
  <SEO />
</ProductProvider>

// Or explicitly:
<SEO product={product} url={`/products/${product.slug}`} />
<SEO category={category} url={`/categories/${category.slug}`} />
<SEO title="Sök" noIndex />
```

`<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.**

<Tabs>
  <Tab title="<SEO /> only">
    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 />`.
  </Tab>

  <Tab title="generateMetadata">
    ```ts theme={null}
    import { buildSeo, toNextMetadata } from "@quickbutik/kit"
    import { JsonLd } from "@quickbutik/kit/react"

    export async function generateMetadata({ params }) {
      const product = await getProduct((await params).slug)
      return toNextMetadata(buildSeo({ product, ...defaults }))
    }

    export default async function Page({ params }) {
      const product = await getProduct((await params).slug)
      const tags = buildSeo({ product, ...defaults })
      return <><JsonLd data={tags.jsonLd} />{/* … */}</>
    }
    ```

    Next's `Metadata` has no slot for JSON-LD, so pair it with `<JsonLd>`, or the rich results are lost.
  </Tab>
</Tabs>

## Web components

```html theme={null}
<qb-product slug="cotton-tee">
  <qb-seo base-url="https://minbutik.se" product-path="/products/{slug}" currency="SEK" organization></qb-seo>
</qb-product>
```

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.

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

## Other stacks

```ts theme={null}
import { buildSeo, serializeJsonLd } from "@quickbutik/kit"

const tags = buildSeo({
  product,
  baseUrl: "https://minbutik.se",
  currency: "SEK",
  url: `/products/${product.slug}`,
  shop,
})

// tags.title, tags.description, tags.canonical, tags.robots, tags.openGraph, tags.twitter
for (const node of tags.jsonLd) {
  // <script type="application/ld+json">{serializeJsonLd(node)}</script>
}
```

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

| Option | Notes |
| - | - |
| `baseUrl` | Storefront origin. **Required** for canonicals and absolute image URLs |
| `siteName` | Defaults to the shop's name |
| `titleTemplate` | `%s` is the page title. Defaults to `"%s · {siteName}"` |
| `locale` | BCP 47 for OpenGraph (`sv_SE`). Derived from the shop when absent |
| `defaultImage` | Fallback share image |
| `twitterSite` / `twitterCreator` | `@handle`s |
| `currency` | ISO 4217. A fallback: offers use the product's own `product.currency` first. Needed only for a platform older than that field |
| `productPath` | `"/products/{slug}"`; `{slug}` and `{id}` substitute |
| `categoryPath` | The same for categories |
| `shop` | For site name, logo, locale and `Organization` data |
| `organization` | Emit `Organization` structured data |

`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

```ts theme={null}
import {
  productJsonLd, breadcrumbJsonLd, organizationJsonLd,
  productAvailability, variantAvailability, serializeJsonLd,
  formatSchemaPrice, currencyDecimals, absoluteUrl, applyTitleTemplate, plainText,
} from "@quickbutik/kit"
```

* **`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](/kit/concepts/currencies) 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.