Multi-tenancy
One deploy serves many shops. Tenant resolution happens at the edge by hostname, then every downstream call carries explicit { shop, locale }. The App Router never sees an un-tenanted request.
Nordcom Commerce is multi-tenant by hostname. There is no per-shop deployment — adding a new
shop is a row in the Convex shops table, no redeploy. Middleware reads the request's
hostname, resolves the shop, and rewrites the URL into /[domain]/[locale]/… before the App
Router takes over.
The resolution chain
The middleware at apps/storefront/src/proxy.ts is the single entry point for every request.
- Inbound request arrives with a raw
Host:header (primary domain, alias, or dev shorthand). - Middleware calls
Shop.findByDomain(hostname)— resolved through the Convexdb/shopsquery seam — after normalizing the host (strips ports,.localhost, Vercel preview suffixes). - Locale resolves via the fallback chain: path segment →
Accept-Languagematched against the shop's available locales → shop's default locale. - URL rewrites to
/[domain]/[locale]/…— the App Router receives the tenanted path. - Every data call downstream carries
{ shop, locale }explicitly.
On a NotFoundError from step 2, the middleware rewrites to
SERVICE_DOMAIN/status/unknown-shop/. Unknown hosts in dev fall back to
nordcom-demo-shop.com so contributors can boot without seeding their own tenant.
Tenant context is never implicit. There is no getCurrentShop() singleton, no
request-scoped global, no AsyncLocalStorage tenant slot. Every Shopify call
constructs a fresh ShopifyApolloApiClient({ shop, locale }); every CMS reader
takes { shop, locale } in its argument object. New data-fetching helpers must
follow this contract.
Data fetching contract
Every reader in @nordcom/commerce-cms/api follows the same shape: tenant-scope on
shop.id, apply locale, fall back to shop default, return null when the entity does
not resolve. Throws NotFoundError when a slug is structurally invalid.
import { getArticle } from '@nordcom/commerce-cms/api';
export default async function ArticlePage({ params }: {
params: Promise<{ shop: ShopDomain; locale: LocaleCode; slug: string }>;
}) {
const { shop, locale, slug } = await params; // params is a Promise in Next.js 16
const article = await getArticle({ shop, locale, slug });
if (!article) notFound();
return <ArticleView article={article} />;
}The same contract applies to Shopify API clients:
ShopifyApolloApiClient({ shop, locale }) constructs an Apollo client scoped to that
tenant and locale. See Data Fetching for per-tenant
Apollo pool behavior and the React cache() dedup layer.
Never derive the shop from a singleton or environment variable. The deployment serves
many shops simultaneously — a wrongly-cached tenant context would leak data across
shops. If you find yourself reaching for a global, stop and pass { shop, locale }
through the call stack instead.
The tenant-scoped cache invariant
Every cached entity's tags include a shop key. Revalidating one tenant cannot touch another tenant's entries. Inside a tenant-scoped cache, entities are further keyed by locale — locale is a qualifier under tenant, never above it.
cms.<tenantId>.<collection>.<key> // individual entity
cms.<tenantId>.<collection> // collection sweep
cms.<tenantId> // whole-tenant sweepFor helpers that construct these tags, use the utilities in @nordcom/commerce-cms/cache.
Never hand-format these strings — the invariant is enforced by the helper, and a typo
silently breaks invalidation. See Caching for the full tag schema.
Run revalidateTag('shopify.<shop.id>', 'max') to flush all Shopify-sourced data for a
single tenant. The coarsest CMS sweep is revalidateTag('cms.<tenantId>'). Both leave
all other tenants' cache entries untouched.