Skip to content
Elara One docs

SEO for headless storefronts

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

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

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

Your URLFetch
/products/oak-desk-lampGET /v1/products/oak-desk-lamp
/collections/lightingGET /v1/collections/lighting and GET /v1/collections/lighting/products
/about-usGET /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

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.

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

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:

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:

TagUse
<title>seo.title, else title
Meta descriptionseo.description, else excerpt
Open Graph imageseo.image_url
Canonicalseo.canonical_url, else the page's own URL
Robotsnoindex 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

  • 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

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.

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

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

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

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:

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"
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.

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.