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 | null

src/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 — tenant Header global (logo, nav, mega-menu tiles).
  • FooterApi({ shop, locale })footer.ts — tenant Footer global (sections, social, legal, copyright).
  • BusinessDataApi({ shop, locale })store.ts — tenant support/contact fields.
  • InfoBarApi({ shop, locale })info-bar.ts — targeted alias over BusinessDataApi for the info-bar surface.
  • ProductMetadataApi({ shop, locale, handle }) and CollectionMetadataApi({ 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> and shopify.<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 typecacheLife
Layout generateMetadata'max'
Product / Collection generateMetadata'days'
Blog / Article generateMetadata'days'
Search results generateMetadata'minutes'
Cartnot 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:

  1. Decide whether it's tenant-scoped, entity-scoped, or both.
  2. Extend src/cache.ts's schema with the new entity (or reuse an existing one).
  3. Call safeCacheTag(...shopifyCache.keys.<entity>({ tenant, qualifier, ...params }).tags) inside the wrapper, before delegating to the source.
  4. Confirm /api/revalidate knows how to derive the same tags from the relevant Shopify webhook topic — add a topic mapping in @tagtree/shopify if needed.

On this page