MemberHub: a 19-tool SaaS in one Django monolith
Calendar to invoicing, all sharing one API style, one auth model and one billing engine — built and run by one person.
- routes
- ~390
- in one NinjaAPI, 26 modules
- components
- ~95
- about 54,000 lines, no bundler
- frontend strings
- 1,200
- bilingual, plus 145 backend
- fee floor
- 1.5%
- down from 5% by tier
Context
MemberHub is a multi-tenant SaaS platform for small membership-driven organizations — clubs, studios, schools, community groups, small merchants. The customer shape is consistent: one to a few administrators serving tens to hundreds of members, often bilingual, often taking payments, never with a developer on staff. The alternative on offer is stitching together five or six separate products.
So the product is nineteen tools an organization turns on individually: membership, events, classes, reservations, a calendar, invoicing, a store, email campaigns, chat, surveys, web forms, a page builder, a no-code database, files, an image editor, reports, support ticketing, automations and a shared whiteboard.
I am the only engineer and also the operator, and that shaped everything. Nineteen tools by one person only works if they are nineteen variations on one thing: one API style, one auth model, one tenancy rule, one billing engine, one deployment. Anything needing its own service, database or auth story would never have shipped.
What I built
- Browser — Lit 3 SPA, no bundler
- Django — 19 tool apps, ~390 routes
- PostgreSQL — one schema
- Object storage — files, images
- MQTT broker — real-time
- Stripe — and Connect
- SendGrid — email in and out
- Browser → Django (JWT · X-Org-Slug)
- Django → PostgreSQL
- Django → Object storage
- Django → MQTT broker
- Django → Stripe
- Django → SendGrid
The backend is a single Django 5.2 monolith serving a JSON API through django-ninja on PostgreSQL. Every tool is a Django app repeating one four-file split: models with an Organization foreign key on each top-level row, Pydantic schemas in separate admin and public variants, services, and thin routers. All of it converges on one NinjaAPI instance — 26 route modules, about 390 routes. The naming convention is load-bearing: authenticated routers mount at /api/<tool> and public ones at /api/public/<tool>, and because authentication is the API-wide default a public endpoint must opt out explicitly — which makes "is this endpoint public?" a greppable property.
The frontend is a Lit 3 single-page application of about 95 web components and roughly 54,000 lines of JavaScript, with no bundler, no npm and no build step: browser-native ES modules through a CDN import map, a hand-rolled 115-line hash router, and a service worker that serves JavaScript network-first so fresh code wins.

Auth splits into three planes. Staff get Auth0 JWTs, verified server-side against a cached key set. Tenancy is an X-Org-Slug header the server resolves and then authorizes. Members and shoppers, who will never make an account to see one order, get email magic links: a one-time token that mints a separate, longer-lived session token.
Billing is one Stripe integration carrying two money flows. Organizations subscribe to the platform (bundles at $49, $149 and $299 a month, plus per-tool plans from $9.99); their own customers pay them directly through Stripe Connect direct charges, with the platform taking an application fee that steps from 5% down to 1.5% as the tier rises. The product is bilingual English/Japanese throughout, down to per-record content overlays with AI-assisted translation.
Decisions
django-ninja instead of DRF. Endpoints are plain typed functions; Pydantic schemas give validation and OpenAPI docs without serializer ceremony, and double as the frontend's contract. The consequence I did not anticipate: django-ninja authenticates after Django middleware runs, which later dictated where tenant authorization could live.
Shared-schema multi-tenancy. One database, one schema, an organization foreign key on every scoped row. Schema- or database-per-tenant would isolate tenants in the engine rather than in my code, but multiplies migrations and operations by the number of customers. I took the cheap operational path knowing isolation was now entirely application-enforced. It bit me once, badly.
No bundler. Lit is about 5KB and a build pipeline was complexity the project did not need. The payoff is zero build latency and a deploy that is collectstatic plus a cache-buster bump; the price, accepted knowingly, is a runtime dependency on CDNs and cache invalidation as human discipline rather than a compiler guarantee.
Direct charges, not destination charges. The charge, the customer record, the refunds and the disputes all live on the organization's own Stripe account: they keep their customer relationships, and I never hold their money. The cost is that an organization must finish Stripe onboarding before memberships, orders or invoices can take a payment.
Limits as data, not code paths. The quota system is two dictionaries mapping tool to resource caps, free and pro, overridable per plan, plus one limit check in each create endpoint. Enforcement is therefore create-time and opt-in: an organization that downgrades keeps the twelve boards it has and cannot make a thirteenth — commercially fine, and adding limits to a new tool is two dictionary entries and two lines.
What went wrong
The header everyone trusted. Middleware resolved X-Org-Slug to an organization and attached it to the request; endpoints read it and scoped their queries to it. Every piece looked responsible, and nothing did authorization: an authenticated user from one organization could send another's slug and read and write its orders, members, billing and files — a header-shaped IDOR latent across roughly 200 endpoints, with no user-visible symptom.
The obvious fix was unavailable: middleware runs before django-ninja authenticates, so there is no authenticated user to check. The rule went into one view-level helper instead — missing or unknown slug 400, non-member 403, always — with the verdict memoized on the request and the membership lookup deliberately read-only, since the existing user helper writes a login timestamp and a read path must not write. Conversion was total, not targeted: nineteen tool route modules carried a byte-identical copy-pasted helper, and each became a one-line delegation. A red regression test was committed before the fix; the suite stood at 173 tests green at merge.
The last catch came from the whole-feature review, not the implementation: three plain-Django views registered ahead of the API include shadow the protected routes and were invisible to every grep for the request attribute. Convention-based audits are blind to code that ignores the convention.
The join that only worked one way. In the no-code database tool, joining two sheets worked one way and silently half-failed the other — the joined sheet's columns simply absent, no error. I fixed the query-execution service, tested it directly, got correct results, and the live app still returned broken rows. That contradiction means you are editing dead code, and I was: the engine existed as three near-identical copies, and the live one was a plain-Django view shadowing the API route.
The bug itself was a tautology. The direction check asked whether the foreign-key column belonged to the join's from-sheet, which is true by definition of how joins are stored, so the engine always took the forward path and matched nothing. The right question is not who owns the column but whose data is already in the result set. All three copies track that now; months later, the same topology is what the authorization review nearly missed.
Outcome
Nineteen tools are live, every one with a free tier, in one Django process I operate myself. The product is fully bilingual — over 1,200 frontend keys and 145 backend strings — and the authorization hardening merged with 173 tests green.
The costs are explicit and open. There is no task queue, so email retries are database state rather than background jobs. One tool's import-time error takes down the whole API — the price of one readable URL map. Per-endpoint role granularity is deferred, and the jump from free to $49 is a pricing cliff I have identified but not smoothed.