DEV Community

Cover image for AURORA Commerce Pro: adding Sanity support without touching the storefront
Franck LEBAS
Franck LEBAS

Posted on

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 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()
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
})
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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 || ''
}
Enter fullscreen mode Exit fullscreen mode

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 demo
AURORA 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.

Top comments (0)