Skip to content
On this page

Data-driven sections ​

Most sections render static merchant content — titles, banners, marketing strips. Data-driven sections are different: they fetch live data from api-stores (products, collections, posts) and render the result.

Examples: ProductGrid, ProductRelated, CollectionsGrid, BlogList, ProductRecentlyViewed.

This page covers the pattern.

You need auth set up to actually see results. Static-content sections render fine in unauthenticated dev (the <MyPromoStrip :config="..." /> pattern). Data-driven sections call $storeino.products.search(...) and will get 401s if your dev environment has no token. Set NUXT_PUBLIC_AUTH_TOKEN in souk-theme/.env first — see Getting started → Connecting to a store, Mode 2.

The shape of a data-driven section ​

vue
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useNuxtApp } from '#app';
import { useT } from '~/composables/useT';
import { useStyles, type ElementStylePayload } from '~/composables/useStyles';

// 1. Type the API response shape.
interface Product {
  _id?: string;
  slug?: string;
  name?: string;
  price?: { salePrice?: number; comparePrice?: number };
  images?: { src?: string }[];
}

// 2. Type the section config.
interface ProductGridConfig {
  title?: string | Record<string, string>;
  limit?: number;
  source?: 'latest' | 'best-sellers' | 'manual';
  productIds?: string[];
  styles?: Record<string, ElementStylePayload>;
}

const props = defineProps<{ config: ProductGridConfig }>();
const t = useT();

// 3. State.
const products = ref<Product[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);

// 4. Fetch.
async function fetchProducts() {
  loading.value = true;
  error.value = null;
  try {
    const { $storeino } = useNuxtApp() as any;
    const { data } = await $storeino.products.search({
      limit: props.config.limit ?? 8,
      sort: { createdAt: -1 },
    });
    products.value = (data?.results ?? []) as Product[];
  } catch (err) {
    error.value = err instanceof Error ? err.message : String(err);
    products.value = [];
  } finally {
    loading.value = false;
  }
}

// 5. Fetch on mount + on config change.
onMounted(fetchProducts);
watch(() => [props.config.limit, props.config.source], fetchProducts);
</script>

<template>
  <section>
    <p v-if="loading">Loading…</p>
    <p v-else-if="error" class="text-red-500">{{ error }}</p>
    <p v-else-if="!products.length" class="text-muted">No products yet.</p>
    <div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
      <a v-for="p in products" :key="p._id" :href="`/products/${p.slug}`">
        <img v-if="p.images?.[0]?.src" :src="p.images[0].src" :alt="p.name || ''" />
        <p>{{ p.name }}</p>
        <p>{{ p.price?.salePrice }}</p>
      </a>
    </div>
  </section>
</template>

That's the full pattern. Five steps: types in, state, fetch function, lifecycle wiring, defensive template.

The $storeino client ​

useNuxtApp().$storeino is the typed API client injected by plugins/storeino.ts. It already handles:

  • Base URL (api-stores)
  • Auth token (read from cookie / header, see theme-manager memory note)
  • Language + currency query params (automatic from useMainStore)
  • Validating status codes
  • Error formatting

You don't construct fetch calls by hand. Use the client.

Available resources ​

MethodReturns
$storeino.products.search(params){ data: { results: Product[], paginate: {...} } }
$storeino.products.get(id){ data: Product }
$storeino.collections.search(params){ data: { results: Collection[], paginate: {...} } }
$storeino.collections.get(id){ data: Collection }
$storeino.pages.search(params){ data: { results: Page[], paginate: {...} } }
$storeino.pages.get(id){ data: Page }
$storeino.brands.search(params){ data: { results: Brand[], paginate: {...} } }

There are more (orders, reviews, customers) but those are for cart/account/post-checkout sections, not content sections. See plugins/storeino.ts for the full list.

Common search params ​

ts
$storeino.products.search({
  limit: 8,                                    // max results
  sort: { createdAt: -1 },                     // newest first
  sort: { 'counters.purchase': -1 },           // best sellers
  '_id-in': ['65abc...', '65def...'],          // specific IDs (comma-joined under the hood)
  'collections-in': ['col_summer'],            // products in a collection
  status: 'PUBLISH',                           // published only (default for most resources)
});

The -in suffix is api-stores filter syntax. Other operators: -gte, -lte, -ne. See storeino-docs/api/ for the full reference.

The picker → fetch handshake ​

Most data-driven sections offer a picker ({ type: "picker", resource: "product", multiple: true }). The picker stores IDs — not the resolved objects. Your component fetches the objects.

The ProductGrid does exactly this:

ts
const pickedIds = (props.config.productIds || []).filter(Boolean);

if (pickedIds.length > 0) {
  // Picker took precedence → fetch those specific products
  const { data } = await $storeino.products.search({
    '_id-in': pickedIds,
    limit: pickedIds.length,
  });
  // Map back into picker order (search results aren't guaranteed sorted)
  const byId = new Map(data?.results?.map((p) => [p._id, p]) ?? []);
  products.value = pickedIds.map((id) => byId.get(id)).filter(Boolean);
} else {
  // No picks → fall through to auto source
  // ...
}

Always re-sort search results back into the merchant's pick order. The API doesn't preserve client-side ordering.

SSR vs CSR for data-driven sections ​

The ProductGrid example above fetches on onMounted — that's client-side only. The section renders empty during SSR, then fills in on the browser.

Why not SSR-fetch? Two reasons:

  1. First paint speed. Hero, slider, banners dominate above-the-fold; a product grid below the fold can wait a beat.
  2. SSR cache invalidation. Theme-manager caches SSR responses for 2 minutes. If we baked product data into the cached HTML, new products wouldn't appear until cache expiry.

For sections where SEO matters (e.g. listing pages that need products in the initial HTML for crawlers), use useFetch instead — that's Nuxt 3's SSR-aware fetch helper. But it's an advanced pattern; ask Abdel before adding SSR product fetching to a new section.

Loading / empty / error states ​

A real data-driven section has at least three states the template must handle:

vue
<template>
  <section>
    <header v-if="config.title">{{ t(config.title) }}</header>

    <div v-if="loading" class="text-muted">Loading…</div>
    <div v-else-if="error" class="text-red-500">{{ error }}</div>
    <div v-else-if="!products.length" class="text-muted">{{ t(config.emptyMessage) || 'Nothing here yet.' }}</div>
    <div v-else class="grid">
      <ProductCard v-for="p in products" :key="p._id" :product="p" />
    </div>
  </section>
</template>

The merchant should be able to customize the empty message via a manifest field — empty product grids are common in demo / new stores and the wording matters.

Re-fetching on config change ​

The merchant edits limit from 8 to 12 in the inspector. Without a watcher, the grid stays at 8. Add:

ts
watch(
  () => [
    props.config.limit,
    props.config.source,
    JSON.stringify(props.config.productIds || []),
  ],
  fetchProducts
);

Stringify array fields — watch does shallow compare on arrays by default. The performance cost is trivial for short ID lists.

Caching considerations ​

Two things to know:

  1. Shared ProductCard. Most product-data sections use <ProductCard> (in components/ProductCard.vue). It handles image fallback, sale-price decoration, hover effects. Use it unless you have a strong reason not to.
  2. Don't fetch on every component mount globally. A page with three ProductGrid instances triggers three fetches. That's usually fine, but if you find yourself fetching the same data many times, lift the fetch into a Pinia store or use Nuxt's useState for SSR-shared cache.

Common mistakes ​

  1. Forgetting the loading flag. Without it, the section renders empty for a moment, then pops with content — looks broken.
  2. Hard-coded API endpoint. Don't fetch('https://api-stores.storeino.com/...') directly. Use $storeino so auth, language, and base URL stay environment-aware.
  3. Not handling empty arrays. If data.results is undefined, [...undefined] throws. Always coalesce: data?.results ?? [].
  4. No re-fetch on config change. See the watcher above.
  5. Mixing rendering and fetching. Fetch in <script setup>, render in <template>. Don't put await calls in computed properties.

When to ship a data-driven section ​

If your section is just content rendering (banner, heading, promo strip), keep it static. Add API fetches only when:

  • The data is product/collection/post specific (lists, details, related items)
  • The merchant explicitly picks resources via a picker
  • The output legitimately depends on store state (cart, wishlist counts)

Static content sections deploy and behave predictably. Data-driven sections add network failure modes, loading states, cache concerns. Don't add the complexity unless you need it.

Released under the MIT License.