Skip to content

2026 · Solo full stack · shipped

JomJual

Multi-tenant e-commerce SaaS for Malaysian merchants. One subdomain per store, tenant isolation enforced at the tRPC procedure layer, payments through Curlec with HMAC-verified webhooks.

31 tables, 16 routers, 132 procedures

  • Next.js 15
  • TypeScript
  • tRPC 11
  • Drizzle ORM
  • PostgreSQL (Supabase)
  • Razorpay / Curlec
  • EasyParcel
  • Turborepo

Every merchant gets a subdomain. kedai.jomjual.my and butik.jomjual.my run off the same database, the same codebase, and the same Curlec account. The isolation is not convention. It is enforced at the infrastructure layer before any application code runs. This is JomJual: solo-built, production-shipped, and still the project that made me think hardest about money.

Context

What JomJual is

JomJual ("Let's Sell" in Malay) is a multi-tenant e-commerce SaaS targeting Malaysian merchants who want a branded online store without configuring hosting, payment gateways, or shipping integrations themselves. A merchant signs up, picks a theme, uploads products, and their store is live on a {slug}.jomjual.my subdomain within minutes. Payments go through Curlec (Razorpay's Malaysian entity), which means FPX, TNG eWallet, DuitNow QR, and cards all work out of the box. EasyParcel handles real-time courier rates for Pos Laju, J&T, and the others.

The platform runs as a Turborepo monorepo with three Next.js 15 apps: the storefront (customer-facing, subdomain-routed), the dashboard (merchant admin), and the landing page. Four shared packages sit underneath: @jomjual/api (the tRPC backend), @jomjual/db (Drizzle ORM schema), @jomjual/ui (shared component library), and @jomjual/i18n (English, Malay, Chinese, Tamil).

The reason this exists is partly practical and partly the same reason any side project exists: there was a gap I kept bumping into. Malaysian merchants are underserved by global platforms: Shopify's ringgit support is fine but FPX integration is thin, and local alternatives are either too simple or too expensive for a small kedai. Building this gave me a reason to go deep on multi-tenancy, payment settlement, and fintech-adjacent state machines, all of which I wanted to understand beyond tutorial depth.

Architecture

Multi-tenancy without magic

The foundation is subdomain-based soft multi-tenancy. All stores share one Postgres database on Supabase, one connection pool, one deployment. What keeps them apart is a single invariant threaded through every layer: every query is scoped to a store_id.

Service graph

JomJual service graph: a central dashboard hub connected by lines to individual subdomain store nodes.

The mechanism starts in Next.js middleware, which runs on every request before any page or API code. The middleware parses the hostname, extracts the slug (everything before .jomjual.my), and writes it into a x-store-slug request header. The tRPC context reads that header and resolves it to a store_id. From that point every procedure (whether fetching products, reading orders, or updating store settings) has store_id available and filters on it. There is no way to write a procedure that forgets this; the merchant and storefront middleware layers enforce it structurally.

The RBAC model has four tiers: public, storefront, merchant, admin. Public procedures are accessible to anyone. Storefront procedures require a resolved store context. Merchant procedures require Supabase auth and that the authenticated user owns the store making the request. Admin procedures are platform-only. Each tier is a wrapper that composes onto the base tRPC procedure: it adds the check, throws if it fails, and passes context down if it passes. Because this happens in @jomjual/api and both the dashboard and storefront import from the same package, the rules cannot drift between apps.

01 · Middleware extracts subdomain

Every request entering the storefront hits Next.js middleware first. It reads the hostname, pulls the subdomain segment, and injects x-store-slug into the request headers before forwarding.

middleware.ts
// apps/storefront/middleware.ts (simplified)
export function middleware(req: NextRequest) {
const host = req.headers.get('host') ?? ''
const slug = host.split('.jomjual.my')[0]

const headers = new Headers(req.headers)
headers.set('x-store-slug', slug)

return NextResponse.next({ request: { headers } })
}

The tRPC context then resolves that slug to a store_id via a cached DB lookup, and every procedure down the call stack filters on it. Cross-tenant leakage is architecturally impossible, not just convention.

02 · Webhook signature, timing-safe

Curlec is Razorpay in a Malaysian wrapper, and Razorpay webhooks lie. Not maliciously, just often enough that you cannot trust the payload blindly. The webhook handler does two things before touching any order state: it computes an HMAC-SHA256 digest of the raw request body using the webhook secret, then compares it against the X-Razorpay-Signature header using timingSafeEqual.

webhook-verify.ts
import { createHmac, timingSafeEqual } from 'crypto'

function verifyWebhookSignature(
body: string,
signature: string,
secret: string,
): boolean {
const expected = createHmac('sha256', secret)
  .update(body)
  .digest('hex')
return timingSafeEqual(
  Buffer.from(expected),
  Buffer.from(signature),
)
}

The reason not to use === is timing attacks. A string equality check short-circuits the moment characters differ. An attacker measuring response latency can, in theory, infer characters one at a time. timingSafeEqual always takes the same time regardless of where the mismatch is. The order is only marked paid after this check passes. Idempotency key on the payment_id prevents double-marking if the webhook fires twice.

03 · Settlement lifecycle is a state machine

Merchant payouts do not move directly from "order paid" to "money in bank." There is a four-stage lifecycle that models what actually happens in a payment platform:

pending (order created) → on_hold (3 days after delivery confirmed) → available (ready to withdraw) → withdrawn (bank transfer initiated)

The 3-day hold exists to give time for returns and disputes before the platform owes a merchant anything. Each settlement record stores a full financial breakdown: product amount, shipping collected, Curlec gateway fee, platform commission (2%), and any discount amounts. The 2% commission is taken at settlement time, not at order creation. This matters if an order is partially refunded.

Bank details are snapshotted at payout request time. A merchant can change their bank account after requesting a payout and the existing payout still goes to the right account. The snapshot pattern also applies to order line items and payment transactions, so order history survives product edits or deletions.

  • 31

    pgTable definitions in @jomjual/db

  • 16

    tRPC domain routers

  • 132

    tRPC procedures total

  • 4

    storefront languages (EN, MY, ZH, TA)

  • 4

    theme variants (minimal, elegant, bold, soft)

  • 2%

    platform commission, taken at settlement

Learnings

  1. Drizzle's type inference for relations collapses at nested joins. The solution was explicit select shapes on every query rather than relying on the ORM to infer the output type. More code to write upfront; zero runtime surprises.
  2. Soft multi-tenancy with a shared schema works until you forget to add store_id to a new table. Added a lint rule in the db package that warns on any pgTable definition without a store_id column (platform-level tables are excluded via an allowlist). Caught two misses before they shipped.
  3. tRPC 11 ships with React Query 5 under the hood. The migration from v4 query key shapes to v5 broke cached query invalidation in the dashboard in non-obvious ways. Lesson: read the changelog, not just the migration guide. The changelog had the specific key structure change; the migration guide skipped it.
  4. Webhook idempotency is not optional. The first load test sent three copies of every Curlec webhook (retry logic in their delivery system). Without an idempotency key on payment_id the order would have been marked paid three times and inventory decremented three times. Fixed before it reached production, but only because the load test was thorough.
  5. i18n with locale-prefixed routing and four character sets (including Noto Sans SC and Noto Sans Tamil) inflated the initial JS bundle more than expected. Moved font loading to per-locale CSS variables so Tamil and Chinese fonts only load on locale-prefixed routes. Worth the extra complexity.

FAQ

Why tRPC over REST?
End-to-end type safety across three apps sharing one backend package. When a procedure's input or output type changes, TypeScript surfaces every broken callsite at compile time. REST with OpenAPI gives you the same guarantee but with a code-gen step in the middle. tRPC skips the code-gen step; the schema is the code.
Why Drizzle over Prisma?
Drizzle is closer to SQL. The query builder maps almost 1:1 to the SQL it generates, which matters when you care about composite indexes, partial indexes, and store-scoped uniqueness constraints. Prisma's abstraction layer is excellent for CRUD-heavy apps but makes it harder to reason about what query actually runs. Given the multi-tenancy requirements, being close to the SQL was worth it.
Is this just Shopify? Why not use Shopify?
Shopify is expensive at scale and its Malaysian payment integration (FPX via Curlec, DuitNow QR, TNG eWallet) requires third-party apps that add latency and cost. JomJual has Curlec built in, EasyParcel for local courier rates baked into checkout, and 4-language support including Tamil. The architecture is also fully owned: no app store dependency, no platform lock-in for features like the homepage builder or settlement lifecycle. That said, if you have a large catalogue and strong Shopify Plus needs, use Shopify.
What is live now versus what is planned?
The technical platform is production-ready: multi-tenancy, payments, shipping, vouchers, settlements, reviews, and the full merchant dashboard. The current gap is GTM. Marketing channels, partner onboarding, and the merchant acquisition funnel are in progress. The admin panel's payout processing and platform voucher grant system are live but not yet stress-tested at real merchant volume.