Skip to main content
← All posts
E-commerceArchitectureMulti-tenant

How We Built a Multi-Tenant E-commerce Platform

7 min read

Most small businesses that want an online store face the same choice: pay for a custom build, or squeeze into a generic template. We took a third route. We built one commerce platform, a web storefront, a store dashboard and a mobile app, and made it multi-tenant, so a new branded store goes live without a new build or a new server.

It already runs as a buy-and-sell car marketplace, and you can try a live demo store. This post explains how it works and what we learned building it.

What a store owner gets

Each store gets:

  • A web storefront with the store's own name, logo, colours and favicon
  • A dashboard to manage products, categories, banners and orders
  • A mobile app (Flutter, for Android and iOS)
  • Arabic, English and Kurdish content throughout

The goal is that launching a new store is a form, not a project: pick a name, a primary colour, a logo and a business type, and the store is created.

One backend, one database, many stores

There are two ways to build a white-label platform:

  1. A separate stack per customer. Each store gets its own backend container and database. Isolation is simple, but every new customer means more infrastructure to provision, patch and pay for.
  2. A shared stack with a tenant key. All stores use the same backend and database, and every row belongs to a tenant.

We started with the first plan on paper and chose the second for the web platform. The stack is Node.js with Express, Prisma and MySQL, with React on the front end. Every tenant-scoped table (products, categories, banners, orders, reviews, wishlists and so on) carries a tenantId. The tenant is resolved from the request's hostname, so shop-name.example.com and the platform's own domain reach the same code and see different data.

The trade-off is plain: you save a lot of operational work, and you take on the responsibility of never leaking one tenant's data to another.

Branding at runtime, not build time

If every store needed its own build, we'd be back to per-customer deployments. Instead, the storefront asks the API who the visitor is looking at. The API returns that tenant's name, colours, logo and favicon, and the app applies them as CSS variables when it loads.

Two details make this safe in practice:

  • It fails soft. If the branding call is slow or fails, the page falls back to the default look instead of breaking.
  • Shoppers don't pick colours. On a branded store the palette is fixed by the owner, so the colour picker is hidden. The owner chose that identity.

Isolation is a structural problem

The biggest lesson of the project is about isolation. Our first approach was to filter by tenant in each controller. That depends on every developer remembering every time, and one forgotten filter is a data leak.

So the filtering lives in one place: a Prisma middleware that injects the tenant into every query on a tenant-scoped model, stamps it on every create, and blocks cross-tenant updates and deletes. The tenant is carried through the request with Node's AsyncLocalStorage.

Then we wrote an isolation test that runs against a real database, with two tenants and checks that neither can see the other's data. It found a real bug that our mocked unit tests had missed. The tenant context wasn't reaching the database layer, because of a subtle interaction between AsyncLocalStorage and a callback that returns a promise instead of being an async function. The fix was small. The lesson is not:

If tenant isolation is your product's promise, test it against the real database, with real tenants, not with mocks.

That test now runs as a guard, and we apply the same rule to anything involving money.

Orders and payments

A store isn't a store without checkout. The platform has a cart, orders and order history for shoppers, and an order queue for the store owner with statuses from pending through confirmed to delivered.

A few decisions are worth noting:

  • Prices are never trusted from the client. Checkout recomputes each line from the product's current price and discount on the server.
  • Order lines freeze the price at the time of purchase, and they survive even if the product is later deleted, because an order is a historical record.
  • Checkout is a single transaction. It creates the order, writes its lines and clears the cart together, or not at all.
  • Cash on delivery is the default. Card payments use a hosted checkout page where a store has them configured. An order only becomes "paid" when a signed webhook says so, never because the browser says it came back from the payment page, since that redirect can be skipped or faked.

Going live: provisioning without drama

Making a store reachable means DNS, a TLS certificate and a web-server route. At first, someone ran a script by hand after each new store, and we learned the hard way that "someone remembers to do it" doesn't hold. A store was created and, without its certificate, showed visitors a browser security warning.

Now a small job runs on the server every few minutes, looks for active stores without a site configuration, and sets them up. A few choices keep it safe:

  • One certificate per store rather than a single wildcard. A wildcard renewal that fails would take every store down at once, while a per-store certificate can only ever affect one.
  • A lock, so two runs never overlap.
  • One failure doesn't stop the rest. A store that isn't ready is retried on the next run.
  • No new privileges for the backend. The job runs on the host and the backend container gains no extra access, which keeps the blast radius small if the container is ever compromised.

A full dashboard per store

Early on, the store dashboard was a small page with products, categories, orders and settings. Store owners wanted more, so we adapted our full admin dashboard: users, products, banners, notifications, roles and permissions. The existing permission system already hid anything a role couldn't use, so a store owner sees only their own store's data.

What we'd tell another team

  • Pick tenancy early. Shared database or separate stacks is the decision everything else hangs on.
  • Enforce isolation in one place and test it against a real database.
  • Make branding data, not code. Runtime theming is what makes a no-rebuild launch possible.
  • Automate provisioning as soon as more than one person might forget a step.
  • Treat payment confirmation as a server-side fact, never a browser redirect.

Want a store of your own?

SolidStack launches this platform rebranded for your business, with your name, colours, domain and languages, and runs it for you. See the demo store, read about our services, or get in touch.

See your own AWS account this clearly

Free plan — connect your first account in under 2 minutes, no credit card.

Start your first scan