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. SetNUXT_PUBLIC_AUTH_TOKENinsouk-theme/.envfirst — see Getting started → Connecting to a store, Mode 2.
The shape of a data-driven section
<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
| Method | Returns |
|---|---|
$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
$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:
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:
- First paint speed. Hero, slider, banners dominate above-the-fold; a product grid below the fold can wait a beat.
- 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:
<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:
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:
- Shared
ProductCard. Most product-data sections use<ProductCard>(incomponents/ProductCard.vue). It handles image fallback, sale-price decoration, hover effects. Use it unless you have a strong reason not to. - Don't fetch on every component mount globally. A page with three
ProductGridinstances 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'suseStatefor SSR-shared cache.
Common mistakes
- Forgetting the
loadingflag. Without it, the section renders empty for a moment, then pops with content — looks broken. - Hard-coded API endpoint. Don't
fetch('https://api-stores.storeino.com/...')directly. Use$storeinoso auth, language, and base URL stay environment-aware. - Not handling empty arrays. If
data.resultsis undefined,[...undefined]throws. Always coalesce:data?.results ?? []. - No re-fetch on config change. See the watcher above.
- Mixing rendering and fetching. Fetch in
<script setup>, render in<template>. Don't putawaitcalls 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.