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:
- Create
server/utils/catalog/adapters/your-cms.ts - Export a
CatalogAdapterobject withlistProductsandgetProductBySlug - Normalize your CMS payload into
CatalogProduct - Register your provider in
adapters/index.ts - 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)