← Retour aux articles

AURORA Commerce Pro: adding Sanity support without touching the storefront

Publié le 1 avril 2026 · 4 min de lecture

nuxtsanityvuetailwindcss

Cet article est traduit automatiquement depuis l'anglais. Voir sur dev.to

When I published my first article about AURORA Commerce, a former colleague left a comment that stuck with me:

“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?”

He was right. And it was exactly the limitation I hadn’t addressed publicly.

So I fixed it. Here’s how.


The constraint

The original AURORA Commerce storefront reads products from YAML files via Nuxt Content:

const { data: products } = await useAsyncData('products', () =>
  queryCollection('products')
    .where('active', '=', true)
    .all()
)

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.

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.

I wanted something cleaner.


The adapter pattern

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.

Storefront pages
      ↓
API routes (/api/products, /api/products/[slug])
      ↓
getCatalogAdapter(event)
      ↓
NuxtContentAdapter | SanityAdapter | YourCustomAdapter
      ↓
CatalogProduct (normalized)
      ↓
mapProductForApi(product, locale)
      ↓
ProductApiItem (locale-aware response)

The core files:

server/utils/catalog/
  types.ts         # CatalogProduct, ProductApiItem, CatalogAdapter
  map.ts           # mapProductForApi + resolveLocale
  adapters/
    index.ts       # Resolver — reads CATALOG_PROVIDER env var
    content.ts     # Nuxt Content (YAML) implementation
    sanity.ts      # Sanity implementation

The contract

Two interfaces define the whole system.

CatalogAdapter is the contract every provider must implement:

// server/utils/catalog/types.ts
export interface CatalogAdapter {
  listProducts: (event: any) => Promise<CatalogProduct[]>
  getProductBySlug: (event: any, slug: string) => Promise<CatalogProduct | null>
}

CatalogProduct is the normalized internal shape - same fields, same types, regardless of the source. The storefront never sees raw Sanity or YAML data.

ProductApiItem is what the API routes actually return - locale-aware, with priceCents already computed:

export interface ProductApiItem {
  id: number
  name: string        // resolved from title / titleFr
  slug: string
  description: string // resolved from description / descriptionFr
  priceCents: number  // Math.round(price * 100) — euros in YAML, cents to the client
  imageUrl: string
  imageUrls: string[]
  sizes: string[]
  // ... rest of the fields
}

That priceCents conversion happens in map.ts, not in the checkout route. One place, no surprises.


The resolver

Switching providers is an environment variable:

# Use Nuxt Content YAML (default)
CATALOG_PROVIDER=content

# Use Sanity
CATALOG_PROVIDER=sanity
SANITY_PROJECT_ID=your_project_id
SANITY_DATASET=production
SANITY_API_VERSION=2025-01-01
SANITY_TOKEN=optional_read_token

The resolver in adapters/index.ts reads CATALOG_PROVIDER and returns the right adapter. API routes call it once:

// server/api/products.get.ts
export default defineEventHandler(async (event) => {
  const adapter = getCatalogAdapter(event)
  const locale = resolveLocale(event) // reads ?locale= query param
  const products = await adapter.listProducts(event)
  return products.map((p) => mapProductForApi(p, locale))
})

resolveLocale reads the locale query param and returns 'fr' | 'en'. The locale resolution stays server-side - mapProductForApi picks the right field (titleFr vs title, descriptionFr vs description, etc.) before sending the response.

No if/else scattered across the codebase. No Sanity imports in storefront pages. The checkout route is unchanged.


The Sanity adapter

The Sanity adapter is a set of pure functions - no class, no instance state:

// server/utils/catalog/adapters/sanity.ts
export const sanityAdapter: CatalogAdapter = {
  async listProducts() {
    const client = createSanityClient()
    const raw = await client.fetch(`*[_type == "product"]`)
    return raw.map(normalizeSanityProduct)
  },

  async getProductBySlug(_, slug) {
    const client = createSanityClient()
    const raw = await client.fetch(
      `*[_type == "product" && (slug.current == $slug || slug == $slug)][0]`,
      { slug }
    )
    return raw ? normalizeSanityProduct(raw) : null
  }
}

The slug query handles both Sanity’s native slug.current shape and plain string slugs - useful when migrating data from YAML.

Normalizing images

Sanity images can come in three different shapes depending on how the schema is set up:

const normalizeImages = (value: unknown): string[] => {
  if (!Array.isArray(value)) return []

  return value
    .map((image) => {
      // Plain URL string
      if (typeof image === 'string') return image

      // Sanity asset reference with resolved URL
      if (image?.asset?.url) return String(image.asset.url)

      // Direct URL field
      if (image?.url) return String(image.url)

      return ''
    })
    .filter(Boolean)
}

If your Sanity schema uses asset references without URL projection, add url to your GROQ query. The adapter handles the rest.

Full product normalization

const normalizeSanityProduct = (entry: any): CatalogProduct => ({
  productId: Number(entry.productId || 0),
  title: String(entry.title || ''),
  titleFr: optionalString(entry.titleFr),
  slug: normalizeSlug(entry.slug),
  price: Number(entry.price || 0),
  category: String(entry.category || 'tailoring') as CatalogProduct['category'],
  images: normalizeImages(entry.images),
  sizes: toStringArray(entry.sizes),
  sizeChart: Array.isArray(entry.sizeChart) ? entry.sizeChart : [],
  reviews: Array.isArray(entry.reviews) ? entry.reviews : [],
  // bilingual fields follow the same optionalString pattern
})

If Sanity changes its schema, only this function needs updating. The storefront, the cart, and the Stripe checkout don’t know.


One more thing: image resilience

Sanity image URLs can occasionally fail - CDN misses, unpublished assets. The product page now handles this gracefully:

// app/pages/produit/[slug].vue
const failedPrimaryImages = ref<string[]>([])

const primaryImageSrc = computed(() => {
  if (selectedImage.value) return selectedImage.value
  return product.value?.imageUrls.find(
    (img) => !failedPrimaryImages.value.includes(img)
  ) || product.value?.imageUrl || ''
})

const handlePrimaryImageError = () => {
  const current = primaryImageSrc.value
  if (current) failedPrimaryImages.value.push(current)
  const next = product.value?.imageUrls.find(
    (img) => !failedPrimaryImages.value.includes(img)
  )
  selectedImage.value = next || ''
}

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.


Adding your own adapter

The architecture is open by design. Adding Contentful, Hygraph, or a custom REST API is four steps:

  1. Create server/utils/catalog/adapters/your-cms.ts
  2. Export a CatalogAdapter object with listProducts and getProductBySlug
  3. Normalize your CMS payload into CatalogProduct
  4. Register your provider in adapters/index.ts
  5. Set CATALOG_PROVIDER=your-cms

The storefront pages, the cart, and the Stripe checkout don’t change. That’s the point.


What this unlocks

The original Starter was honest about its constraint: YAML works for a technical operator, not for a client who needs a visual interface.

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.

Live demoAURORA Commerce on Gumroad - Starter €29 / Pro €59


Thanks to Robin for the feedback that drove this iteration.

Built with Nuxt 4, Vue 3, Nuxt Content v3, Sanity, Pinia, Tailwind CSS, Stripe, and Bun.