[{"data":1,"prerenderedAt":18},["ShallowReactive",2],{"blog-article-aurora-commerce-pro-adding-sanity-support-without-touching-the-storefront-j29-fr":3},{"slug":4,"title":5,"description":6,"tags":7,"published_at":12,"reading_time_minutes":13,"cover_image":14,"canonical_url":15,"html":16,"cached":17},"aurora-commerce-pro-adding-sanity-support-without-touching-the-storefront-j29","AURORA Commerce Pro: adding Sanity support without touching the storefront","When I published my first article about AURORA Commerce, a former colleague left a comment that stuck...",[8,9,10,11],"nuxt","sanity","vue","tailwindcss","2026-04-01T15:37:25Z",4,"https:\u002F\u002Fmedia2.dev.to\u002Fdynamic\u002Fimage\u002Fwidth=1000,height=420,fit=cover,gravity=auto,format=auto\u002Fhttps%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3rqmqsecbswt0catg8bw.png","https:\u002F\u002Fdev.to\u002Ffrancklebas\u002Faurora-commerce-pro-adding-sanity-support-without-touching-the-storefront-j29","\u003Cp>When I published my first article about AURORA Commerce, a former colleague left a comment that stuck with me:\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>“The idea is great, but your repo-as-database approach assumes a technical operator. What about non-technical clients who need a visual CMS interface?”\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cp>He was right. And it was exactly the limitation I hadn’t addressed publicly.\u003C\u002Fp>\n\u003Cp>So I fixed it. Here’s how.\u003C\u002Fp>\n\u003Chr>\n\u003Ch2>The constraint\u003C\u002Fh2>\n\u003Cp>The original AURORA Commerce storefront reads products from YAML files via Nuxt Content:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-typescript\">const { data: products } = await useAsyncData('products', () =&gt;\n  queryCollection('products')\n    .where('active', '=', true)\n    .all()\n)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>This works beautifully for a technical operator. For a client who needs to update a product description on a Tuesday afternoon without touching a terminal - it doesn’t.\u003C\u002Fp>\n\u003Cp>The naive fix would be to hardcode Sanity queries throughout the storefront pages. But that means the storefront becomes coupled to a specific CMS. Swap providers and you’re touching every page.\u003C\u002Fp>\n\u003Cp>I wanted something cleaner.\u003C\u002Fp>\n\u003Chr>\n\u003Ch2>The adapter pattern\u003C\u002Fh2>\n\u003Cp>The idea is simple: introduce a single resolver between the API routes and the data source. Storefront pages don’t query products directly - they call API routes. API routes call the adapter. The adapter knows where data comes from. Nothing else does.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-plaintext\">Storefront pages\n      ↓\nAPI routes (\u002Fapi\u002Fproducts, \u002Fapi\u002Fproducts\u002F[slug])\n      ↓\ngetCatalogAdapter(event)\n      ↓\nNuxtContentAdapter | SanityAdapter | YourCustomAdapter\n      ↓\nCatalogProduct (normalized)\n      ↓\nmapProductForApi(product, locale)\n      ↓\nProductApiItem (locale-aware response)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The core files:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-plaintext\">server\u002Futils\u002Fcatalog\u002F\n  types.ts         # CatalogProduct, ProductApiItem, CatalogAdapter\n  map.ts           # mapProductForApi + resolveLocale\n  adapters\u002F\n    index.ts       # Resolver — reads CATALOG_PROVIDER env var\n    content.ts     # Nuxt Content (YAML) implementation\n    sanity.ts      # Sanity implementation\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Chr>\n\u003Ch2>The contract\u003C\u002Fh2>\n\u003Cp>Two interfaces define the whole system.\u003C\u002Fp>\n\u003Cp>\u003Ccode>CatalogAdapter\u003C\u002Fcode> is the contract every provider must implement:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-typescript\">\u002F\u002F server\u002Futils\u002Fcatalog\u002Ftypes.ts\nexport interface CatalogAdapter {\n  listProducts: (event: any) =&gt; Promise&lt;CatalogProduct[]&gt;\n  getProductBySlug: (event: any, slug: string) =&gt; Promise&lt;CatalogProduct | null&gt;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>CatalogProduct\u003C\u002Fcode> is the normalized internal shape - same fields, same types, regardless of the source. The storefront never sees raw Sanity or YAML data.\u003C\u002Fp>\n\u003Cp>\u003Ccode>ProductApiItem\u003C\u002Fcode> is what the API routes actually return - locale-aware, with \u003Ccode>priceCents\u003C\u002Fcode> already computed:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-typescript\">export interface ProductApiItem {\n  id: number\n  name: string        \u002F\u002F resolved from title \u002F titleFr\n  slug: string\n  description: string \u002F\u002F resolved from description \u002F descriptionFr\n  priceCents: number  \u002F\u002F Math.round(price * 100) — euros in YAML, cents to the client\n  imageUrl: string\n  imageUrls: string[]\n  sizes: string[]\n  \u002F\u002F ... rest of the fields\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>That \u003Ccode>priceCents\u003C\u002Fcode> conversion happens in \u003Ccode>map.ts\u003C\u002Fcode>, not in the checkout route. One place, no surprises.\u003C\u002Fp>\n\u003Chr>\n\u003Ch2>The resolver\u003C\u002Fh2>\n\u003Cp>Switching providers is an environment variable:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\"># Use Nuxt Content YAML (default)\nCATALOG_PROVIDER=content\n\n# Use Sanity\nCATALOG_PROVIDER=sanity\nSANITY_PROJECT_ID=your_project_id\nSANITY_DATASET=production\nSANITY_API_VERSION=2025-01-01\nSANITY_TOKEN=optional_read_token\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The resolver in \u003Ccode>adapters\u002Findex.ts\u003C\u002Fcode> reads \u003Ccode>CATALOG_PROVIDER\u003C\u002Fcode> and returns the right adapter. API routes call it once:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-typescript\">\u002F\u002F server\u002Fapi\u002Fproducts.get.ts\nexport default defineEventHandler(async (event) =&gt; {\n  const adapter = getCatalogAdapter(event)\n  const locale = resolveLocale(event) \u002F\u002F reads ?locale= query param\n  const products = await adapter.listProducts(event)\n  return products.map((p) =&gt; mapProductForApi(p, locale))\n})\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>resolveLocale\u003C\u002Fcode> reads the \u003Ccode>locale\u003C\u002Fcode> query param and returns \u003Ccode>'fr' | 'en'\u003C\u002Fcode>. The locale resolution stays server-side - \u003Ccode>mapProductForApi\u003C\u002Fcode> picks the right field (\u003Ccode>titleFr\u003C\u002Fcode> vs \u003Ccode>title\u003C\u002Fcode>, \u003Ccode>descriptionFr\u003C\u002Fcode> vs \u003Ccode>description\u003C\u002Fcode>, etc.) before sending the response.\u003C\u002Fp>\n\u003Cp>No \u003Ccode>if\u002Felse\u003C\u002Fcode> scattered across the codebase. No Sanity imports in storefront pages. The checkout route is unchanged.\u003C\u002Fp>\n\u003Chr>\n\u003Ch2>The Sanity adapter\u003C\u002Fh2>\n\u003Cp>The Sanity adapter is a set of pure functions - no class, no instance state:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-typescript\">\u002F\u002F server\u002Futils\u002Fcatalog\u002Fadapters\u002Fsanity.ts\nexport const sanityAdapter: CatalogAdapter = {\n  async listProducts() {\n    const client = createSanityClient()\n    const raw = await client.fetch(`*[_type == &quot;product&quot;]`)\n    return raw.map(normalizeSanityProduct)\n  },\n\n  async getProductBySlug(_, slug) {\n    const client = createSanityClient()\n    const raw = await client.fetch(\n      `*[_type == &quot;product&quot; &amp;&amp; (slug.current == $slug || slug == $slug)][0]`,\n      { slug }\n    )\n    return raw ? normalizeSanityProduct(raw) : null\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The slug query handles both Sanity’s native \u003Ccode>slug.current\u003C\u002Fcode> shape and plain string slugs - useful when migrating data from YAML.\u003C\u002Fp>\n\u003Ch3>Normalizing images\u003C\u002Fh3>\n\u003Cp>Sanity images can come in three different shapes depending on how the schema is set up:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-typescript\">const normalizeImages = (value: unknown): string[] =&gt; {\n  if (!Array.isArray(value)) return []\n\n  return value\n    .map((image) =&gt; {\n      \u002F\u002F Plain URL string\n      if (typeof image === 'string') return image\n\n      \u002F\u002F Sanity asset reference with resolved URL\n      if (image?.asset?.url) return String(image.asset.url)\n\n      \u002F\u002F Direct URL field\n      if (image?.url) return String(image.url)\n\n      return ''\n    })\n    .filter(Boolean)\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>If your Sanity schema uses asset references without URL projection, add \u003Ccode>url\u003C\u002Fcode> to your GROQ query. The adapter handles the rest.\u003C\u002Fp>\n\u003Ch3>Full product normalization\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-typescript\">const normalizeSanityProduct = (entry: any): CatalogProduct =&gt; ({\n  productId: Number(entry.productId || 0),\n  title: String(entry.title || ''),\n  titleFr: optionalString(entry.titleFr),\n  slug: normalizeSlug(entry.slug),\n  price: Number(entry.price || 0),\n  category: String(entry.category || 'tailoring') as CatalogProduct['category'],\n  images: normalizeImages(entry.images),\n  sizes: toStringArray(entry.sizes),\n  sizeChart: Array.isArray(entry.sizeChart) ? entry.sizeChart : [],\n  reviews: Array.isArray(entry.reviews) ? entry.reviews : [],\n  \u002F\u002F bilingual fields follow the same optionalString pattern\n})\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>If Sanity changes its schema, only this function needs updating. The storefront, the cart, and the Stripe checkout don’t know.\u003C\u002Fp>\n\u003Chr>\n\u003Ch2>One more thing: image resilience\u003C\u002Fh2>\n\u003Cp>Sanity image URLs can occasionally fail - CDN misses, unpublished assets. The product page now handles this gracefully:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-typescript\">\u002F\u002F app\u002Fpages\u002Fproduit\u002F[slug].vue\nconst failedPrimaryImages = ref&lt;string[]&gt;([])\n\nconst primaryImageSrc = computed(() =&gt; {\n  if (selectedImage.value) return selectedImage.value\n  return product.value?.imageUrls.find(\n    (img) =&gt; !failedPrimaryImages.value.includes(img)\n  ) || product.value?.imageUrl || ''\n})\n\nconst handlePrimaryImageError = () =&gt; {\n  const current = primaryImageSrc.value\n  if (current) failedPrimaryImages.value.push(current)\n  const next = product.value?.imageUrls.find(\n    (img) =&gt; !failedPrimaryImages.value.includes(img)\n  )\n  selectedImage.value = next || ''\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Failed images are tracked in a ref. The computed picks the next available one automatically. No broken image placeholder, no flash - just a silent fallback.\u003C\u002Fp>\n\u003Chr>\n\u003Ch2>Adding your own adapter\u003C\u002Fh2>\n\u003Cp>The architecture is open by design. Adding Contentful, Hygraph, or a custom REST API is four steps:\u003C\u002Fp>\n\u003Col>\n\u003Cli>Create \u003Ccode>server\u002Futils\u002Fcatalog\u002Fadapters\u002Fyour-cms.ts\u003C\u002Fcode>\u003C\u002Fli>\n\u003Cli>Export a \u003Ccode>CatalogAdapter\u003C\u002Fcode> object with \u003Ccode>listProducts\u003C\u002Fcode> and \u003Ccode>getProductBySlug\u003C\u002Fcode>\u003C\u002Fli>\n\u003Cli>Normalize your CMS payload into \u003Ccode>CatalogProduct\u003C\u002Fcode>\u003C\u002Fli>\n\u003Cli>Register your provider in \u003Ccode>adapters\u002Findex.ts\u003C\u002Fcode>\u003C\u002Fli>\n\u003Cli>Set \u003Ccode>CATALOG_PROVIDER=your-cms\u003C\u002Fcode>\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>The storefront pages, the cart, and the Stripe checkout don’t change. That’s the point.\u003C\u002Fp>\n\u003Chr>\n\u003Ch2>What this unlocks\u003C\u002Fh2>\n\u003Cp>The original Starter was honest about its constraint: YAML works for a technical operator, not for a client who needs a visual interface.\u003C\u002Fp>\n\u003Cp>The Pro tier addresses that directly. Same storefront, same Stripe integration, same bilingual setup - but the catalog source becomes a configuration choice, not an architectural constraint.\u003C\u002Fp>\n\u003Cp>→ \u003Ca href=\"https:\u002F\u002Fnuxt-commerce-flame-chi.vercel.app\u002F\">Live demo\u003C\u002Fa>\n→ \u003Ca href=\"https:\u002F\u002Ffrancklebas.gumroad.com\u002Fl\u002Fnuxt-aurora\">AURORA Commerce on Gumroad\u003C\u002Fa> - Starter €29 \u002F Pro €59\u003C\u002Fp>\n\u003Chr>\n\u003Cp>\u003Cem>Thanks to Robin for the feedback that drove this iteration.\u003C\u002Fem>\u003C\u002Fp>\n\u003Cp>\u003Cem>Built with Nuxt 4, Vue 3, Nuxt Content v3, Sanity, Pinia, Tailwind CSS, Stripe, and Bun.\u003C\u002Fem>\u003C\u002Fp>\n",false,1775820116898]