# SEO for headless storefronts

URL: /docs/guides/seo

Server rendering, slugs, CMS page metadata, redirects, canonical URLs, structured data, sitemaps and not-found handling with the Elara One API.

A headless storefront owns its HTML, so it also owns its SEO. The API gives you what search engines need: stable slugs, product and price data for structured data, per-page SEO fields for CMS content, and the merchant's redirects. This guide shows how to put them together.

## Render on the server [#render-on-the-server]

Fetch catalogue and content on your server and send finished HTML. Crawlers then see products, prices and links without running JavaScript, and your server secret stays on the server where it belongs. Catalogue and CMS responses are cached by the engine and cleared when the merchant edits something, so server rendering stays fast.

## URLs from slugs [#urls-from-slugs]

Products, collections and CMS pages each have a `slug`. Build your URLs from them and fetch by slug:

| Your URL | Fetch |
|---|---|
| `/products/oak-desk-lamp` | `GET /v1/products/oak-desk-lamp` |
| `/collections/lighting` | `GET /v1/collections/lighting` and `GET /v1/collections/lighting/products` |
| `/about-us` | `GET /v1/pages/about-us` |

Each of these answers `404 NOT_FOUND` for an unknown slug, and only ever returns published products and pages.

## Meta tags from product data [#meta-tags-from-product-data]

A product has `name`, `description` (which may contain HTML) and `images`, each with a `url`. Use them for the title, meta description and Open Graph image.

```ts title="app/products/[slug]/page.tsx"
import type { Metadata } from 'next'

export async function generateMetadata(props: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await props.params
  const product = await getProduct(slug) // GET /v1/products/{slug}, or null on 404
  if (!product) return {}

  const description = stripHtml(product.description ?? '').slice(0, 155)
  const image = product.images?.[0]?.url

  return {
    title: `${product.name} | Hearth & Oak`,
    description,
    alternates: { canonical: `https://shop.example.com/products/${product.slug}` },
    openGraph: { title: product.name, description, images: image ? [image] : [] },
  }
}
```

## CMS pages [#cms-pages]

`GET /v1/pages/{slug}` returns a published page with `title`, `kind` (`page` or `post`), `excerpt`, `published_at`, `blocks` and an `seo` object the merchant fills in:

```json title="GET /v1/pages/about-us (trimmed)"
{
  "data": {
    "handle": "about-us",
    "slug": "about-us",
    "title": "About Hearth & Oak",
    "kind": "page",
    "excerpt": "Furniture and homeware made in Somerset.",
    "seo": {
      "title": "About us | Hearth & Oak",
      "description": "Who we are and how we make our furniture.",
      "image_url": "https://…/workshop.jpg",
      "canonical_url": null,
      "noindex": false
    },
    "published_at": "2026-09-30T08:00:00+00:00",
    "blocks": ["…"]
  }
}
```

Every key in `seo` is optional, and `seo` is `{}` when none is set. Fall back to the page's own fields:

| Tag | Use |
|---|---|
| `<title>` | `seo.title`, else `title` |
| Meta description | `seo.description`, else `excerpt` |
| Open Graph image | `seo.image_url` |
| Canonical | `seo.canonical_url`, else the page's own URL |
| Robots | `noindex` when `seo.noindex` is `true` |

`GET /v1/pages` lists published pages (`slug`, `title`, `kind`, `excerpt`, `published_at`), newest first. Use `filter[kind]=post` for a blog index.

A preview link (`?preview=…`) shows a draft and is never cached. Always mark preview renders `noindex`.

## Canonical URLs [#canonical-urls]

- **Products:** one canonical URL per product, `/products/{slug}`, whichever collection the shopper came through.
- **Listings:** filters and sorts in the query string create many URLs for the same products. Point the canonical at the unfiltered collection URL, and keep `?page=2` and later pages self-canonical so their products are still found.
- **CMS pages:** use `seo.canonical_url` when the merchant set one.

## Structured data [#structured-data]

Build schema.org JSON-LD from fields the API returns. Prices are integer minor units, so convert to a decimal string without floating-point maths.

```ts title="lib/structured-data.ts"
type Money = { amount: number; currency: string }

// For two-decimal currencies such as GBP.
function decimal({ amount }: Money): string {
  const sign = amount < 0 ? '-' : ''
  const abs = Math.abs(amount)
  return `${sign}${Math.trunc(abs / 100)}.${String(abs % 100).padStart(2, '0')}`
}

export function productJsonLd(product: Product, url: string) {
  const variant = product.variants[0]
  const price = variant.pricing.sale_price ?? variant.pricing.price

  return {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    description: stripHtml(product.description ?? ''),
    sku: variant.sku,
    image: product.images.map((image) => image.url),
    offers: {
      '@type': 'Offer',
      url,
      price: decimal(price),
      priceCurrency: price.currency,
      availability: variant.stock > 0 ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
    },
  }
}

export function breadcrumbJsonLd(trail: Array<{ name: string; url: string }>) {
  return {
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: trail.map((item, index) => ({
      '@type': 'ListItem',
      position: index + 1,
      name: item.name,
      item: item.url,
    })),
  }
}
```

- `GET /v1/products/{slug}` always includes variants with prices, images, collections and offers, so one call has everything.
- For a product with several variants at different prices, use an `AggregateOffer` with `lowPrice` and `highPrice` from the variants' `pricing.price`.
- For the breadcrumb trail, take one of the product's `collections` and walk up through each collection's `parent_id` using `GET /v1/collections`.
- Show the same price in the markup as on the page. If a sale price is shown, use it in the `Offer` too.

## Sitemaps [#sitemaps]

Build the sitemap from paginated lists. Use the maximum page size and follow `meta.last_page`.

```ts title="app/sitemap.ts"
import type { MetadataRoute } from 'next'

const BASE = 'https://shop.example.com'

async function allPages<T>(path: string): Promise<T[]> {
  const items: T[] = []
  for (let page = 1; ; page++) {
    const res = await engineGet<{ data: T[]; meta: { last_page: number } }>(path, new URLSearchParams({ perPage: '100', page: String(page) }))
    items.push(...res.data)
    if (page >= res.meta.last_page) return items
  }
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const [products, collections, pages] = await Promise.all([
    allPages<{ slug: string }>('/products'),
    allPages<{ slug: string }>('/collections'),
    allPages<{ slug: string; published_at: string | null }>('/pages'),
  ])

  return [
    ...products.map((p) => ({ url: `${BASE}/products/${p.slug}` })),
    ...collections.map((c) => ({ url: `${BASE}/collections/${c.slug}` })),
    ...pages.map((p) => ({ url: `${BASE}/${p.slug}`, lastModified: p.published_at ?? undefined })),
  ]
}
```

`GET /v1/pages` does not include the `seo` object, so to leave out pages the merchant marked `noindex`, check `seo.noindex` from `GET /v1/pages/{slug}` when you build the sitemap. For a large catalogue, split the sitemap (Google accepts up to 50,000 URLs in each) and cache it for a while: it costs one request per 100 products.

## Redirects, 404 and 410 [#redirects-404-and-410]

Merchants manage redirects in the store admin, for example when a product is renamed. Your storefront asks the engine whenever a path would otherwise be a not-found:

```bash
curl -G https://api.elaraone.app/api/v1/redirects/resolve \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
  --data-urlencode "path=/products/old-oak-lamp"
```

```json title="Response"
{ "data": { "target_url": "/products/oak-desk-lamp", "status_code": 301 } }
```

`status_code` is `301` or `308` for a permanent move and `302` or `307` for a temporary one. `target_url` is a path on your storefront or a full `https://` address. A path with no redirect answers `404 NOT_FOUND`. Send the path only, with its leading slash; a missing leading slash is added for you.

```ts title="Not-found handling"
import { notFound, permanentRedirect, redirect } from 'next/navigation'

export async function productOrRedirect(slug: string, path: string) {
  const product = await getProduct(slug)
  if (product) return product

  const res = await fetch(`${ENGINE}/redirects/resolve?${new URLSearchParams({ path })}`, { headers: engineHeaders() })
  if (res.ok) {
    const { data } = await res.json()
    if (data.status_code === 301 || data.status_code === 308) permanentRedirect(data.target_url)
    redirect(data.target_url)
  }
  notFound()
}
```

Next.js sends `308` for `permanentRedirect` and `307` for `redirect`; search engines treat these like `301` and `302`. If you need the exact status the merchant chose, issue the redirect from a route handler or your web server instead.

The API answers the same `404 NOT_FOUND` for a product that was removed and one that never existed. If you want to answer `410 Gone` for retired products, keep your own list of retired slugs and check it before returning a `404`. Either way, send a real `404` or `410` status with your not-found page, never a `200`.
