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.

  1. Inbound request arrives with a raw Host: header (primary domain, alias, or dev shorthand).
  2. Middleware calls Shop.findByDomain(hostname) — resolved through the Convex db/shops query seam — after normalizing the host (strips ports, .localhost, Vercel preview suffixes).
  3. Locale resolves via the fallback chain: path segment → Accept-Language matched against the shop's available locales → shop's default locale.
  4. URL rewrites to /[domain]/[locale]/… — the App Router receives the tenanted path.
  5. 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.

Concept

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.

apps/storefront/app/[domain]/[locale]/blog/[slug]/page.tsx
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.

Watch out

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 sweep

For 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.

Tip

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.

Continue exploring

On this page