Skip to content
Last updated Give Feedback

Headless mode

ecommus ships with a default storefront (apps/storefront-astro), but the framework is headless-first: the asset is the API + admin + super-admin. The Astro storefront is one of many possible frontends. You can disable it and render the public surface with whatever stack fits your team — Next.js, Nuxt, SvelteKit, native mobile, a kiosk, a marketplace bridge.

This page explains the contract for “going headless”, the trade-offs, and the minimal code to point a custom frontend at an ecommus API.

ScenarioPick headless?
You already have a Next.js / Nuxt / Astro design system
You want server components / partial hydration / streaming
You need a native mobile storefront (Expo / Swift / Kotlin)
You sell across web + a kiosk + a partner marketplace
You want a single tenant with the default theme❌ (use Astro)
You’re early and don’t want to maintain a custom frontend yet❌ (use Astro)

The default Astro storefront is production-grade — pick it unless you have a specific reason to roll your own.

  • apps/api — the Fastify backend, plugin system, license gates, all middleware.
  • apps/admin — your tenant operators still get the full admin UI (catalog, orders, customers, niche-package activation).
  • apps/super-admin — platform / tenant provisioning, license panel, marketplace publisher.
  • All plugins + themes the API exposes via REST — payment, shipping, marketplace, e-Factura, niche packs.
  • The license model + heartbeat + grace state — unchanged.

What you replace: apps/storefront-astro. Your custom frontend takes over the public-facing rendering.

Headless integrators consume the storefront scope of the API (/api/storefront/*) — public routes, no auth required by default, tenant-resolved by host or X-Ecommus-Tenant header. The full surface is documented at API Reference (auto) and snapshot-tracked under contracts/public-surface.snapshot.json — see Trust commitment for the no-breaking-change guarantee within a major version.

The TypeScript SDK at @ecommus/client is the recommended entry point. It’s a typed thin wrapper over fetch with auto-injected tenant resolution + retry + telemetry hooks.

┌──────────────────────────────────────────────────────────────────────────────┐
│ Your custom frontend(s) │
│ Next.js / Nuxt / SvelteKit / Expo / Swift / Kotlin / kiosk / partner │
│ │
│ Rendered with your design system. Deployed to your CDN. │
└──────────────┬───────────────────────────────────────────────────────────────┘
│ @ecommus/client.storefront.* (or raw fetch)
┌──────────────▼───────────────────────────────────────────────────────────────┐
│ apps/api (Fastify 5, hosted by you OR by us) │
│ /api/storefront/* — public catalog, search, cart, checkout │
│ /api/admin/* — JWT-protected, used by apps/admin │
│ /api/super-admin/* — platform ops, used by apps/super-admin │
└──────────────────────────────────────────────────────────────────────────────┘

Admin + super-admin keep talking to the API as before — they don’t care that the public storefront is now custom.

Headless deploys typically don’t ship apps/storefront-astro at all. Two ways to do it:

  1. Build flag — pass --filter=!apps/storefront-astro to turbo build:

    Terminal window
    npx turbo build --filter=!apps/storefront-astro
  2. Deployment-time — your CD pipeline simply doesn’t deploy the apps/storefront-astro artifact. Caddy / nginx routes the public domain to your custom frontend instead.

You can keep the source in tree (it’s a useful reference + rendering test fixture). Nothing breaks.

A barebones product page in a Next.js 15 / React 19 app:

app/produs/[slug]/page.tsx
import { ecommusClient } from "@ecommus/client";
const client = ecommusClient({
baseUrl: process.env.ECOMMUS_API_URL!, // https://api.mystore.ro
tenant: process.env.ECOMMUS_TENANT_HOST!, // mystore.ro (or X-Ecommus-Tenant)
});
export default async function ProductPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const product = await client.storefront.products.bySlug(slug);
if (!product) return <div>Produs inexistent</div>;
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>
{(product.priceCents / 100).toFixed(2)} {product.currency}
</p>
</article>
);
}

Add to cart and checkout flow:

app/api/cart/add/route.ts
import { ecommusClient } from "@ecommus/client";
const client = ecommusClient({
/* …same as above… */
});
export async function POST(req: Request) {
const { sku, qty, sessionId } = await req.json();
const cart = await client.storefront.cart.addItem({ sessionId, sku, qty });
return Response.json(cart);
}
pages/produs/[slug].vue
<script setup lang="ts">
import { ecommusClient } from "@ecommus/client";
const client = ecommusClient({
baseUrl: useRuntimeConfig().public.ecommusApiUrl,
tenant: useRuntimeConfig().public.ecommusTenantHost,
});
const route = useRoute();
const { data: product } = await useAsyncData(
`product-${route.params.slug}`,
() => client.storefront.products.bySlug(route.params.slug as string)
);
</script>
<template>
<article v-if="product">
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
<p>{{ (product.priceCents / 100).toFixed(2) }} {{ product.currency }}</p>
</article>
</template>

The API resolves tenant in this order (see apps/api/src/middlewares/auth.ts):

  1. X-Ecommus-Tenant header — explicit, wins everywhere
  2. Host header — mystore.ro → tenant whose domain is mystore.ro
  3. Subdomain — acme.platform.cloud → tenant acme (multi-tenant SaaS)
  4. Single-tenant fallback — if only one tenant exists in the DB, it’s used

For headless deploys, set X-Ecommus-Tenant explicitly in the SDK config. It avoids brittle host-based resolution when your frontend lives on a different domain than the API.

Most storefront routes are public (catalog browse, search, view product). A subset requires a session token:

EndpointAuth
GET /api/storefront/products/*none (public)
POST /api/storefront/cart/*session cookie or sessionId
POST /api/storefront/checkout/*session cookie or sessionId
GET /api/storefront/orders/mecustomer JWT (after login)

Customer auth uses standard email/password → /api/storefront/auth/login returns an access JWT (15 min) + refresh token (30 d, rotating). Pass the access JWT as Authorization: Bearer <token> on protected calls. See API Authentication.

Some plugins register slot fillers (e.g. niche-booking adds a date picker to product pages). In the default Astro storefront these render automatically. In headless mode, your frontend renders them.

Two patterns:

  1. Server-render the slot HTML — call GET /api/storefront/slots/:slotName?productId=... to get the HTML string the plugin would emit, inline it in your page. Simplest, no JS coupling.
  2. Read the slot manifest, render yourself — call GET /api/storefront/slots-manifest?theme=<id> to get the slot definitions + default fillers as JSON, render with your own components. More work, full control.

Pattern 1 is recommended for niche-pack-style plugins where the customer doesn’t customise the rendering. Pattern 2 is for teams who want every UI atom to be theirs.

AspectDefault Astro storefrontHeadless
Time to first paintFast (Astro SSR + zero-JS by default)Depends on your stack
Plugin slot renderingAutomaticYou wire it
SEOBuilt-inYou own it
Theme manifest UI customizationThrough themes/* packagesYou build it
Updates from usFree, every releaseStorefront — yours; API + admin — free
Lock-inLow (themes/* are open contracts)Lower (you control the public surface)

Headless trades convenience for control. The framework doesn’t punish you for choosing it — admin, super-admin, plugin slot-rendering APIs, OpenAPI spec, license model are all designed to support both modes equally.

We’re building two reference repos:

  • examples/headless-nextjs/ — Next.js 15 + React 19 + Tailwind, full storefront flow (browse, PDP, cart, checkout). Coming soon.
  • examples/headless-nuxt/ — Nuxt 3 + Vue 3, same scope. Coming soon.

Until those land, the snippets above + the auto-generated API reference are the canonical starting point. Open an issue if you want a different stack scaffold next (SvelteKit / Remix / Astro-as-consumer).