Building SellerZonee: A Multi-Tenant SaaS Storefront Engine for Instagram Sellers
How I designed and built SellerZonee — a multi-tenant SaaS application empowering Instagram merchants with catalog management, isolated storefronts, and streamlined checkouts.
By Uttam Thapa · · SaaS
⚡ Executive Summary (TL;DR)
Instagram sellers run their entire business inside DM threads — stock in a notebook, prices retyped a hundred times a day, payments confirmed by screenshot.
SellerZonee turns that thread into a branded storefront. This is how the multi-tenant architecture works: one shared PostgreSQL
database with a tenant_id discriminator, tenant-scoped Express middleware that makes cross-merchant leakage structurally impossible,
slug-based storefront resolution from a single React bundle, and the composite index that took catalog reads from 340 ms to under 18 ms.
Figure 1: A merchant storefront and its order dashboard, both served from one shared application tier.
The Problem: A Business Run Out of a DM Inbox
Instagram direct messaging is how millions of small businesses, artisan creators, and boutique merchants sell online today. It works right up until it doesn't.
Once a seller crosses roughly thirty orders a week, the thread stops being a sales channel and becomes an operational liability.
🚨 What actually breaks at scale
- Inventory drift. Stock lives in a notebook, so two customers get sold the last item.
- Repeated price answers. The same three questions, answered manually, all day.
- Screenshot payments. Confirmation is a photo of a bank app, verified by eye.
- No order history. "Where is my parcel?" means scrolling back through weeks of chat.
SellerZonee — built with React, Node.js, and PostgreSQL — converts unstructured DM ordering into a dedicated, branded storefront with a product catalog,
a real cart, and automated order tracking. The seller keeps their audience on Instagram and moves the transaction somewhere it can be counted.
Choosing a Multi-Tenant Model
When you are building for thousands of independent sellers, the tenancy decision is the architecture decision — it constrains migrations, provisioning speed,
per-tenant cost, and the blast radius of every bug you will ever write. Two patterns were on the table.
| Dimension |
Database per tenant |
Shared DB + tenant_id |
| Isolation |
Strongest — physical separation |
Logical, enforced in the data layer |
| Migrations |
Run N times, partial-failure states |
Run once |
| Provisioning a new seller |
Create + migrate + connect a database |
One INSERT |
| Cost at 1,000 small tenants |
Dominated by idle capacity |
Dominated by actual traffic |
| Main risk |
Operational sprawl |
A single forgotten WHERE clause |
SellerZonee's tenants are small merchants, not enterprises with compliance auditors. Idle-capacity cost and migration sprawl would have dominated the bill,
so the shared-database model won — on the condition that the "forgotten WHERE clause" risk was engineered away rather than left to code review.
Making Cross-Tenant Leakage Structurally Impossible
The failure mode of shared-database tenancy is always the same: one query that forgets its tenant filter and quietly serves merchant A's orders to merchant B.
Relying on every developer to remember the filter on every query is not a strategy. The tenant scope belongs one layer below the query.
// tenantScope.ts — resolve the tenant once, at the edge of the request.
import type { RequestHandler } from 'express';
export const withTenant: RequestHandler = async (req, res, next) => {
const tenantId = req.auth?.tenantId ?? (await resolveTenantFromSlug(req.params.storeSlug));
if (!tenantId) return res.status(404).json({ error: 'Storefront not found' });
// Every repository call downstream reads the scope from here, never from
// ad-hoc route parameters — so a route cannot accidentally widen it.
req.tenant = { id: tenantId };
next();
};
// repositories/products.ts — the tenant filter is not optional.
export function listProducts(tenant: Tenant, categoryId?: string) {
return db.product.findMany({
where: { tenantId: tenant.id, categoryId },
orderBy: { createdAt: 'desc' },
});
}
Every repository function takes the resolved Tenant as its first argument. A query that forgets the scope does not fail a review — it fails to compile.
That single typing decision removed an entire category of data-leak bug from the codebase.
💡 Production tip
If you are on PostgreSQL, back the application-level scope with Row-Level Security as a second net. Set a per-connection
app.tenant_id and write policies against it; then even a raw SELECT run by hand during an incident cannot cross a tenant boundary.
Defence in depth costs one migration and buys you a night's sleep.
Subdomain Routing and Dynamic Storefront Resolution
Each merchant receives a storefront slug — for example sellerzonee.com/store/artisan-crafts. On a React and Vite frontend, the router inspects the
incoming URL at runtime and hydrates the storefront's identity before the catalog paints:
// Dynamic tenant resolution
const pathParts = window.location.pathname.split('/');
const storeSlug = pathParts[1] === 'store' ? pathParts[2] : null;
if (storeSlug) {
fetchStorefrontMetadata(storeSlug).then((tenantData) => {
setTheme(tenantData.branding); // accent colour, logo, banner
setCatalog(tenantData.products);
});
}
Branding is data, not code. A merchant changing their accent colour writes a row; nothing is rebuilt and nothing is redeployed. One React bundle serves every
storefront, which means a performance fix ships to all of them at once.
Core Features Built for Instagram Sellers
🏪
Instant store builder
Upload products, set variant pricing for sizes, colours and add-ons, attach images. Stock counts decrement as orders land.
💳
Checkout and payment verification
Gateway payment or manual transfer upload, followed by automated SMS and WhatsApp confirmation links.
📊
Merchant order dashboard
Live queue filtered by Pending, Processing, Shipped and Delivered, with revenue metrics.
Two Problems That Only Appear in Production
The catalog query that got slower every week
Catalog reads were fast in development and steadily worse in production. The query itself never changed:
SELECT * FROM products
WHERE tenant_id = $1 AND category_id = $2
ORDER BY created_at DESC
LIMIT 24;
With a single-column index on tenant_id, PostgreSQL could narrow to one merchant's rows but still had to sort them by created_at on
every request. As total product count grew across hundreds of stores, that sort became the whole cost. A composite index that matches the filter and the
sort order lets the planner walk the index and stop at 24 rows:
CREATE INDEX products_tenant_category_recent_idx
ON products (tenant_id, category_id, created_at DESC);
340ms
Catalog read, before
<18ms
Catalog read, after
1
Index, no code change
The ordering of the columns matters: equality predicates first, the sort key last. Reverse them and the index stops being usable for this query.
Twelve-megabyte product photos
Merchants photograph products on their phones and upload the originals — routinely 12 MB each. Customers arrive from an Instagram link, on mobile data,
with roughly two seconds of patience. Serving originals was never going to work.
Uploads now pass through a Cloudinary transformation pipeline that re-encodes to WebP, generates responsive widths, and strips EXIF data. Storefronts load in
under 1.2 seconds on mobile connections, and the merchant never has to think about image size. If you are building the same pipeline, my longer write-up on
image management for ecommerce covers the upload signing and transformation presets in detail.
✅ Key takeaways
- ✓Pick tenancy by tenant size. Many small tenants favour a shared database; a handful of large regulated ones favour separation.
- ✓Push the tenant scope below the query. Make the filter a required argument so forgetting it is a compile error, not a data leak.
- ✓Index for the filter and the sort together. Equality columns first, sort key last, matching direction.
- ✓Keep theming in data. Storefront branding as rows means instant customisation with no rebuild.
- ✓Assume mobile data. Traffic arrives from a social link, so the image pipeline is a feature, not an optimisation.
Related reading: shared schema versus schema isolation goes deeper on the tenancy
trade-off, and scaling PostgreSQL past 10M rows continues the indexing story.
Frequently asked questions
Should a SaaS use one database per tenant or a shared database?
It depends on tenant size and count. Many small tenants favour a shared database with a tenant discriminator, because idle capacity and migration sprawl dominate the cost otherwise. A handful of large, regulated tenants favour separation.
How do you prevent one tenant seeing another tenant's data?
Make the tenant scope structural rather than a convention. Resolve it once at the request boundary and require it as an argument to every repository function, so a query that omits it fails to compile. Back that with row-level security as a second layer.
Why was the catalog query slow even with an index on tenant_id?
Because the index narrowed the rows but the database still had to sort them. A composite index matching both the filter and the sort order — tenant_id, category_id, created_at DESC — lets the planner walk the index and stop early.
Home · Projects · Blog · Services · Résumé · Contact