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 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
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.
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:
{
"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
- 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=2and later pages self-canonical so their products are still found. - CMS pages: use
seo.canonical_urlwhen 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.
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
AggregateOfferwithlowPriceandhighPricefrom the variants'pricing.price. - For the breadcrumb trail, take one of the product's
collectionsand walk up through each collection'sparent_idusingGET /v1/collections. - Show the same price in the markup as on the page. If a sale price is shown, use it in the
Offertoo.
Sitemaps
Build the sitemap from paginated lists. Use the maximum page size and follow meta.last_page.
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"{ "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.
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.
Cookie consent under PECR
Ask for, record and honour a shopper's cookie choices on a headless storefront, with the banner and cookie table served by the API.
CRM change webhooks
Get a signed HTTPS request whenever a contact, company, task or lead changes in a store's CRM, with ids and field names only.