Docs site — architecture, subsystem reference, integrations catalog, sample-stores reference |
Wiki — FAQ, troubleshooting, decisions log |
Live demo — three running reference stores |
Repo topics — headless-commerce, nextjs, typescript, open-source, and more, in the About panel
Mercatus Liber
“Free market” (Latin). A legitimate, 100% free/open-source alternative to Shopify/Medusa/ Saleor/etc. — headless, AI-agent-accessible, and built to be stood up with a single tool.
A free, MIT-licensed, headless commerce framework: schema-first product/SKU catalog with pluggable database adapters, a marketing catalog genuinely separate from the sales catalog (the gap no existing free/OSS commerce platform actually fills), a real interactive multi-axis product configurator (e.g. color x size) resolving to real per-combination price/stock server-side, a per-page CMS instead of forced whole-site theming, a long-lived cart, adapter-based payments (Stripe first), analytics on by default (PostHog, config-swappable), and a plugin system for everything else (OMS, fulfillment, notifications).
Built as an AI and human commerce tool from the ground up — every capability exposed to a human storefront/admin UI is equally exposed to AI agents via a documented skills/tool catalog and an MCP server, calling the exact same subsystem interfaces. No shadow API, no reduced agent-only surface.
Status (updated 2026-09-22): far past the original core-foundation stage this paragraph
used to describe — the full commerce loop (catalog, marketing catalog, search, PDP, CMS, cart,
checkout, Stripe payments, orders, accounts, inventory, plugins) is done, four real reference
persistence adapters exist (SQLite, Postgres, MongoDB, Convex, plus a Shopify commerce-backend
adapter), admin auth/promotions/bundles/recommendations/advertising/reviews/internal-BI/
fulfillment/shipping/SEO are all real and shipped, and three genuinely distinct, live demo
stores (print-shop, northline, broadleaf) run in production at commerce.mdostal.com
today, two of them on different real database backends (Postgres and Convex). See
.pHive/planning/epic-backlog.md for the full, currently 75-epic build-out backlog and
VISION.md for a narrative “where things stand today.”
Why
Evaluated against every serious free/OSS option (Medusa, Saleor, Vendure, Spree/Solidus,
Shopware, Bagisto, Sylius) plus proprietary options (Snipcart, Swell) for
shop.mdostal.com — a small maker shop needing a real multi-item cart. None fit: wrong
stack, wrong deploy shape, unnecessary infrastructure for a small catalog, or not actually free.
See shop/.pHive/epics/v1-launch/docs/oss-cart-cba.md
for the full CBA that led here.
shop.mdostal.com becomes this project’s first real reference deployment once packages exist
here to consume.
Read next
docs/ARCHITECTURE.md— design principles, subsystem map, repo shape.docs/subsystems/— one doc per subsystem (26 total), each covering purpose, dependencies, responsibilities, explicit non-responsibilities, and open questions.docs/NAMING-CANDIDATES.md— naming history (decided: Mercatus Liber).
Prime directive
No subsystem imports another subsystem’s internals. Everything talks through shared core
types, adapter interfaces, or a typed event bus. See docs/ARCHITECTURE.md → “Prime directive:
no tight coupling” for the test used to catch violations.
Integrations
Mercatus Liber integrates with 12 real third-party adapter packages (packages/adapter-*) plus
two built-in provider integrations (Stripe, PostHog/GA4) — covering persistence, CMS, admin
auth, payments, fulfillment & shipping, image/media, and analytics & insights. Several are
already live in production today (Postgres, Convex, Sanity, Clerk, PostHog); others are real,
built, and unit-tested but currently credential-gated, honestly disclosed rather than claimed
working end to end. See the full Integrations catalog
on the docs site for every package’s real current status and the exact environment variable(s)
that would turn it on.
Configuration
Environment variables apps/reference-storefront actually reads (confirmed by grepping
process.env across the app, not from memory). None are required to run the app locally —
every one has a documented, harmless fallback — but real deployments need the identity-provider
and payment keys to get real behavior instead of a local stand-in.
Admin authentication (subsystem 21, @mercatus-liber/admin-auth + @mercatus-liber/adapter-clerk):
CLERK_SECRET_KEY— Clerk’s Backend API secret key. This is the single signal the app uses to decide whether Clerk is configured at all: when set,/adminis gated by real Clerk authentication (middleware.ts) and the realcreateClerkAdminAuthAdapter()is wired in (lib/services.ts); when unset, Clerk’s middleware/provider are skipped entirely and the app falls back to the zero-infracreateDefaultAdminAuthAdapter()dev adapter.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY— Clerk’s publishable key identifying the Clerk instance, required alongsideCLERK_SECRET_KEY. Corrected 2026-09-22, real-provider-verification (epic 56): an earlier version of this doc claimed a separate, non-NEXT_PUBLIC_-prefixedCLERK_PUBLISHABLE_KEYwas what the server-side SDK (clerkMiddleware(),<ClerkProvider>) reads. That was wrong — confirmed by reading@clerk/nextjs@7.9.1’s own published source (server/constants.js):PUBLISHABLE_KEYis read exclusively fromprocess.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, both server- and client-side. There is no separateCLERK_PUBLISHABLE_KEYthis SDK version ever reads, anywhere. Setting the non-NEXT_PUBLIC_name instead (as this doc previously instructed) leaves the real variable unset, andclerkMiddleware()throwsMissingPublishableKeyErroron every request its broad route matcher covers — i.e. nearly the whole site returns 500, not just/admin— reproduced live against a real Clerk test-mode key during this fix. Seepackages/adapter-clerk/README.mdfor the full writeup, including the separate, expected Clerk-SDK behavior (not a bug) where an unauthenticated non-browser request to/admin— e.g. a barecurlwith noAccept: text/html/Sec-Fetch-Destheaders — gets a genuine 404 instead of a sign-in redirect.ADMIN_DEV_PASSWORD— local-development-only fallback, never a real security boundary. Gates the dev-default admin adapter’s single shared session cookie (seepackages/admin-auth/src/default-adapter.ts): whatever value this is set to must match the cookie value for a session to resolve, and it always resolves to a single synthetic “owner” identity. No per-user sessions, no expiry, no CSRF protection, no rate limiting. Unset means no dev session can ever authenticate. Not used at all onceCLERK_SECRET_KEYis set.
Payments (predates this epic):
STRIPE_SECRET_KEY— Stripe’s secret API key. Without it,lib/services.tsstill constructs the payments adapter (createLazyStripeAdapter), but it defers building the real Stripe client until a payment method is actually called — so every page except checkout keeps working, and checkout itself fails loudly only when actually exercised.STRIPE_WEBHOOK_SECRET— Stripe’s webhook signing secret, used to verify incoming webhook events. Same empty-string fallback asSTRIPE_SECRET_KEYabove.
Analytics (predates this epic):
POSTHOG_API_KEY— PostHog project API key, read server-side. When set,lib/services.tswires the realcreatePostHogAdapter(); when unset, it falls back to a deliberately valid, fully-functionalcreateNoopAdapter()(not an error state).POSTHOG_HOST— optional PostHog host override, only meaningful alongsidePOSTHOG_API_KEY.NEXT_PUBLIC_POSTHOG_KEY— PostHog project API key read client-side (lib/analytics-client.ts), for browser-only interaction/impression events. Without it,trackEvent()is a safe no-op that never touches the network.NEXT_PUBLIC_POSTHOG_HOST— optional client-side PostHog host override; falls back tohttps://us.i.posthog.comwhen unset.
Content (CMS) (@mercatus-liber/cms + @mercatus-liber/adapter-sanity):
SANITY_PROJECT_ID— Sanity project ID. This is the single signallib/services.tsuses to decide whether Sanity is configured at all: when set, the realcreateSanityAdapter()is wired in as the CMS’s persistence; when unset, the app falls back to the zero-infracreateInMemoryCmsAdapter()— no external CMS required to run this app locally.SANITY_DATASET— optional, defaults to"production"(Sanity’s own conventional dataset name) when unset. Only meaningful alongsideSANITY_PROJECT_ID.SANITY_TOKEN— Sanity API token used to authenticate reads/writes against the configured project and dataset. Falls back to an empty string when unset, same posture as the Stripe keys above; only meaningful alongsideSANITY_PROJECT_ID.
AI content copilot (apps/reference-storefront/lib/copilot/, /admin/copilot):
ANTHROPIC_API_KEY— a real Anthropic API key. This is the single signallib/copilot-runtime.ts’sisCopilotConfigured()checks: when set,/admin/copilotruns the real tool-calling loop against the live Anthropic API; when unset, the page renders an honest “AI copilot is not configured for this environment” message instead of a broken or fake UI. Deliberately never set oncommerce.mdostal.com(the shared public demo), and this is not an oversight to “fix” — it’s the intended, permanent posture. A public demo with a shared Anthropic key would let any anonymous visitor burn real API spend on the site owner’s account; the copilot’s backend, UI, permission gating, and tests are all fully real and shippable without it (see.pHive/epics/sanity-challenge-commerce-copilot/docs/design-discussion.md). If you’re running this framework yourself (locally, or your own deployment) and want to actually try the copilot, set this to your own key in your own.env.local/hosting environment — never the shared public one.SANITY_CONTEXT_ORG_ID/SANITY_CONTEXT_ENDPOINT_NAME— optional, only meaningful alongsideSANITY_TOKEN(see Content (CMS) above): a Sanity Context MCP org-scoped token and endpoint name for the copilot’s Sanity Context (Knowledge Base) search tool. Same “bring your own credential” posture asANTHROPIC_API_KEYabove — never set on the shared public demo.
Persistence (catalog) (@mercatus-liber/adapter-sqlite + @mercatus-liber/adapter-postgres):
DATABASE_URL— a Postgres connection string. This is the highest-priority signallib/services.tsuses to decide catalog persistence: when set, a realcreatePostgresAdapter()is wired in, backed by apgPoolconnected to this string; when unset, the app falls back to checkingSQLITE_FILE_PATHnext.SQLITE_FILE_PATH— a filesystem path for a durable, file-backed SQLite database. Only consulted whenDATABASE_URLis unset. When set,lib/services.tswires increateSqliteAdapter(path)against that real file (journal_mode = WAL, durable across restarts) instead of the zero-infra default; when both this andDATABASE_URLare unset, the app falls back tocreateSqliteAdapter(":memory:")— no setup required to run locally, but catalog data resets on every restart. A file-backed database at this path can be backed up and restored withpackages/adapter-sqlite’s own CLI — see that package’s README andapps/docs/content-src/deep-dive/adapters-and-portability.md.
Demo content:
- Demo selection is routed, not env-var-driven —
apps/reference-storefront/lib/demos.ts’sDEMO_REGISTRYholds three real public demo stores, each picked live via the/demo/[demoSlug]/...route param (see.pHive/epics/commerce-landing-and-demo-routing/docs/design-discussion.md§3; this replaced an earlierDEMO_BRANDenv var, no longer read anywhere):print-shop(“The Print Shop”, embroidery/custom prints, opens on theeditorialtheme by default),northline(“Northline Home Tech”, a smart-home installer, opens on thenorthlinetheme by default), andbroadleaf(“Broadleaf & Co.”, an eclectic artisan/handmade-goods shop across Plants, Ceramics & Planters, Textiles & Fiber Arts, and Paper & Ephemera, opens on thevibranttheme by default).
Docs site link:
NEXT_PUBLIC_DOCS_URL— the public URL of the deployedapps/docsdocumentation site, read client-side by the framework landing page’s “Docs” link (app/(landing)/layout.tsx). Unset falls back to a clearly-labeled placeholder URL rather than a broken bare/docslink — the docs site’s real, deployed public domain is an operational decision (DNS/domain binding, Vercel project creation) outside this repo’s scope, not something to hardcode as a guess.
SEO canonical URLs (epic 45, seo-01-dynamic-metadata-and-canonical):
NEXT_PUBLIC_SITE_URL—NEXT_PUBLIC_DOCS_URL’s sibling forapps/reference-storefrontitself: the real deployed base URL (commerce.mdostal.comper.pHive/epics/commerce-landing-and-demo-routing/docs/design-discussion.md) used to build every page’s real absolute<link rel="canonical">URL (lib/site-url.ts). UnlikeNEXT_PUBLIC_DOCS_URL’s decorative placeholder, canonical tags need to actually resolve in local dev too, so unset falls back tohttp://localhost:3000rather than a placeholder.
Keep-alive job (GitHub Actions)
free-tier-resilience-and-keepalive epic (fr-03): .github/workflows/keepalive.yml runs daily
(06:00 UTC cron, plus manual workflow_dispatch) to ping — and, if needed, attempt a real
Management API resume against — the one free backend actually live-wired to a public demo today:
Supabase Postgres (print-shop and northline both share DATABASE_URL; Broadleaf & Co. runs on
Convex, which has no comparable pause/resume concept; MongoDB Atlas isn’t wired into any live
demo right now, so it’s deliberately not pinged — see the workflow file’s own header comment for
the researched Atlas-specific pause/resume semantics if that ever changes). This is a separate
set of credentials from the app’s own DATABASE_URL above — GitHub Actions cannot read Vercel’s
environment variables, so these must be added by hand as their own repository secrets (GitHub repo
→ Settings → Secrets and variables → Actions → New repository secret):
SUPABASE_DATABASE_URL— the same Supavisor pooler connection string as production’sDATABASE_URL(Supabase dashboard → Project Settings → Database → Connection string → URI, transaction-pooler/:6543form). Required for the job to do anything at all — without it the job can’t even ping.SUPABASE_ACCESS_TOKEN— a Supabase account Personal Access Token (supabase.com/dashboard/account/tokens ) with theprojects:writescope (project_admin_writefine-grained permission), used only to call the real Management API restore endpoint (POST /v1/projects/{ref}/restore) if a ping ever fails. Optional in the sense that the job still runs and still pings without it, but can’t attempt a real resume.SUPABASE_PROJECT_REF— the Supabase project’s ref/ID, visible in the dashboard URL and as thepostgres.<ref>pooler username insideDATABASE_URLitself. Only meaningful alongsideSUPABASE_ACCESS_TOKEN.
Every run’s real outcome (already healthy / woke it up / unrecoverable — needs manual check) is
recorded in that run’s own GitHub Actions step summary, and the job fails loudly (visible in the
repo’s Actions tab) on an unrecoverable outcome rather than failing silently. See
.github/scripts/keepalive-supabase.sh’s own header comment for the full Supabase
pause/auto-resume research this logic is built from, and
.github/scripts/keepalive-supabase.test.sh (also run on every CI push/PR) for its dry-run test
harness against fake/injected provider responses.
License
MIT — see LICENSE. Give it away.