Data Fetching
All Shopify queries go through AbstractApi (src/utils/abstract-api.ts). Build one
via ShopifyApolloApiClient({ shop, locale }) (or the public variant). Never call
Apollo directly from a route.
import { ShopifyApolloApiClient } from '@/api/shopify';
const api = await ShopifyApolloApiClient({ shop, locale });
const product = await api.query(/* GraphQL */ PRODUCT_QUERY, { handle });The @inContext(country, language) directive is injected automatically by the Apollo
DocumentTransform from @nordcom/commerce-shopify-graphql. Source operations must
not pre-declare @inContext, $country, or $language — the transform owns them
and will throw DuplicateContextDirectiveError / DuplicateContextVariableError if
they're present.
CMS
CMS content is read straight from the Convex cms/read functions. Every getter
routes through the injectable transport in src/api/_cms-read.ts, which calls
the packages/db server-trust query seam:
import { PageApi } from '@/api/page';
const page = await PageApi({ shop, locale, handle }); // Page | nullsrc/api/page.ts is the storefront-level dispatcher that returns either a
cms- or shopify-provider page; <CMSContent> renders block trees via the
BlockRenderer from @nordcom/commerce-cms/blocks/render, with Shopify-aware
loaders injected from src/cms-loaders.ts.
The read-contract types are generated from the CMS field descriptors; run
pnpm cms:gen after any descriptor change to refresh
packages/cms/src/types/content-types.ts (CI gate: pnpm cms:gen:check).
Storefront-side CMS helper layer
Routes never call the Convex transport directly. Instead they go
through a thin storefront helper layer under apps/storefront/src/api/ that
takes { shop, locale, ... } (matching the ShopifyApolloApiClient contract),
detects next/headers' draftMode() so editor previews see autosaved drafts,
and returns null for unseeded documents so callers can fall back to minimal
chrome:
HeaderApi({ shop, locale })—header.ts— tenantHeaderglobal (logo, nav, mega-menu tiles).FooterApi({ shop, locale })—footer.ts— tenantFooterglobal (sections, social, legal, copyright).BusinessDataApi({ shop, locale })—store.ts— tenant support/contact fields.InfoBarApi({ shop, locale })—info-bar.ts— targeted alias overBusinessDataApifor the info-bar surface.ProductMetadataApi({ shop, locale, handle })andCollectionMetadataApi({ shop, locale, handle })—metadata.ts— CMS overlay (SEO, supplemental blocks, descriptionOverride) on top of a Shopify product/collection.ArticleApi({ shop, locale, slug })—article.ts— CMS overlay for a Shopify article slug (SEO, alt cover/excerpt, extra tags, supplemental Lexical body).BlogApi({ shop, locale, page?, limit?, tag? })—cms-blog.ts— paginated CMS Article listing for custom blog routes.PagesApi({ shop, locale })—page.ts— enumerates CMS pages for sitemap-style consumers.
The dispatcher utilities (toShopRef, populatedMedia, tenantId) live in
apps/storefront/src/api/_cms.ts. Typed fixture factories
(mockHeader, mockFooter, mockBusinessData, etc.) live in
apps/storefront/src/utils/test/fixtures/cms.ts. Tests for each helper sit
next to the implementation (*.test.ts).
Per-request loaders
Every multi-call-site data fetch goes through src/api/_loaders.ts. The
module wraps each underlying function in React's cache() so concurrent
callers within a single request resolve once:
import { ProductApi, Shop } from '@/api/_loaders';
const shop = await Shop.findByDomain(domain);
const [product] = await ProductApi({ api, handle });Always import from @/api/_loaders when fetching Shop, Locales,
Locale, Countries, Product, Collection, Blog, Article, Header,
Footer, InfoBar, ProductMetadata, CollectionMetadata, or Pages.
Direct imports from the source modules (@/api/shopify/product etc.) are
permitted only inside _loaders.ts itself and inside its tests.
The dedup guarantee is a React-server-render property: it only kicks in
inside an active server component render (or other React request scope).
Outside that scope — e.g. inside a Vitest test running on happy-dom —
cache() is effectively a pass-through. Tests should assert wrapping
behavior + delegation, not dedup itself.
Per-tenant Apollo client cache
ShopifyApolloApiClient reuses a single ApolloClient per
${shop.id}::${locale.code} (src/api/_apollo-pool.ts). The Apollo
InMemoryCache survives across requests, so repeat queries within a tenant
hit memory instead of Shopify.
Invalidation is driven by tag-based revalidation: when
/api/revalidate fires, the route handler calls
evictApolloClient({ shopId }) to drop matching pool entries. Without this
hook the InMemoryCache would diverge from Next.js's data cache.
If you add a new code path that mutates Shopify state, ensure a webhook (or explicit revalidate call) targets the affected tenant so the pool gets evicted.
Cache tags
Every entity loader in src/api/_loaders.ts tags its cached payload via
@/cache (built on @tagtree/core). The tag scheme:
- Tenant root:
shopify.<shop.id>andshopify.<shop.id>.<shop.domain>. - Entity:
shopify.<shop.id>.<shop.domain>.product.<handle>,…collection.<handle>,…page.<handle>. Parent collection-list / product-list tags (…products,…collections) also fan out.
When Shopify state mutates (admin webhooks), the route handler at
/api/revalidate calls revalidateTag(...) with the matching tag set
derived from @tagtree/shopify's parseShopifyWebhook — driven by the
exact same cache instance the loaders write to, so the tag schemes are
guaranteed to align. The handler also evicts the per-tenant Apollo pool
entry (see Per-tenant Apollo client cache above).
To invalidate an entire tenant:
revalidateTag('shopify.<shop.id>', 'max').
cacheTag only operates inside a 'use cache' boundary. The loaders wrap
the call in a safeCacheTag helper that silently no-ops outside one (so
generateStaticParams and other build-time callers don't crash). Inside
a 'use cache' page render, the tag bubbles up to the nearest parent
boundary.
Per-route cacheLife
| Route type | cacheLife |
|---|---|
Layout generateMetadata | 'max' |
Product / Collection generateMetadata | 'days' |
Blog / Article generateMetadata | 'days' |
Search results generateMetadata | 'minutes' |
| Cart | not cached¹ |
¹ Cart contents are client-only and never see Next's cache. The
cart page's generateMetadata (titles, robots tags) is per-tenant and
remains at 'max' like other layout-level metadata; the same applies
to account. Where product/collection/blog/article metadata is
already at 'max', don't downgrade it to 'days' without a reason.
Notes for new loaders
If you add a new entity loader to _loaders.ts:
- Decide whether it's tenant-scoped, entity-scoped, or both.
- Extend
src/cache.ts's schema with the new entity (or reuse an existing one). - Call
safeCacheTag(...shopifyCache.keys.<entity>({ tenant, qualifier, ...params }).tags)inside the wrapper, before delegating to the source. - Confirm
/api/revalidateknows how to derive the same tags from the relevant Shopify webhook topic — add a topic mapping in@tagtree/shopifyif needed.