Chibuzor Ezeamaku Case Study
SaaS Platform · Multi-tenant

KPL

A multi-tenant business management platform unifying petroleum marketing, market operations, and rentals — with multi-branch support, role-based access control, audit logging, and expense tracking, built end-to-end on Next.js 15 Server Actions and Supabase.

View on GitHub → ← All work Live · Public repository
What it is

KPL is a single platform that runs three distinct businesses for one organization: petroleum marketing (pumps, inventory, purchases, and daily sales), market operations (a product catalog, inventory, and sales), and rentals (renters, rent locations, payments, and notes). Each lives behind a shared organizational backbone.

That backbone is what makes it more than three CRUD apps: multi-branch tenancy, role-based access control, audit logging, and expense tracking span every domain, so a single deployment serves multiple branches with the right people seeing the right data.

What makes it interesting

Three businesses, one platform

Petroleum, market, and rentals each have their own data model and daily workflows, but share one auth layer, one tenancy model, and one design system — instead of three separate tools the organization would have to stitch together.

Multi-branch, multi-tenant by design

The data model is branch-scoped from the ground up, so one instance cleanly separates each location’s pumps, inventory, sales, and rentals while rolling up to an organization-wide view.

Security enforced in the database

Row Level Security on every table means access rules live in Postgres itself, not just application code. A bug in a handler can’t leak another branch’s data — the database refuses the row.

Server Actions over a REST layer

Mutations run as Next.js Server Actions — typed, co-located with the UI, and validated with Zod on the way in. No hand-written API surface to keep in sync, and far less client/server contract drift.

Audit logging as a first-class concern

Sensitive operations are recorded, so the organization keeps a trail of who changed what — essential when money, inventory, and rent payments move through the same system.

Layered admin model

A single global admin, a configurable set of account admins, and branch-scoped roles give a clear chain of authority without hard-coding people into the codebase.

Architecture

A single Next.js 15 App Router application backed by Supabase, with mutations expressed as Server Actions rather than a separate API tier:

app/            Next.js 15 App Router — routes, layouts, and Server Actions.
                Server Actions are the write path; RSC reads run on the server.
  petroleum/   Pumps, inventory, purchases, daily sales tracking.
  market/      Product catalog, inventory, sales.
  rentals/     Renters, rent locations, payments, notes.
  org/         Branches, roles & access control, audit log, expenses.
components/     shadcn/ui primitives + TanStack tables and Recharts views.
lib/            Supabase clients (anon + service role), Zod schemas, helpers.
supabase/       Postgres schema with Row Level Security on every table.

Request / data flow: a component invokes a Server Action → the input is validated with a Zod schema → the action writes through Supabase → Row Level Security enforces branch and role boundaries in Postgres → the audit log captures the change → the affected route re-renders from server state.

Tech stack
AreaChoiceNotes
FrameworkNext.js 15 · App RouterServer Actions for all mutations; React Server Components for reads.
DatabaseSupabase (Postgres)Row Level Security enabled on every table; multi-tenant, branch-scoped.
AuthSupabase AuthGlobal admin + account-admin allowlist; branch-scoped roles.
UITailwind CSS · shadcn/uiComposable, accessible component primitives.
FormsZod · React Hook FormOne schema validates the form and the Server Action.
TablesTanStack TableHeadless, sortable, filterable data grids.
ChartsRechartsSales and inventory dashboards.
RuntimeNode 22.14.0Pinned via .nvmrc.
DeployCloudflare PagesBuilt with @cloudflare/next-on-pages.
Notable engineering decisions
RLS as the security boundary

Access control is enforced in Postgres with Row Level Security on all tables, not bolted on in the app layer. The application can be wrong and the data still stays isolated — the database is the last line of defense, not the first.

Server Actions instead of a REST API

Writes are Server Actions co-located with the components that call them. That removes an entire API surface, keeps types end-to-end, and lets validation, mutation, and revalidation live in one place.

One schema, two jobs

Each Zod schema validates both the React Hook Form on the client and the Server Action on the server, so the form and the mutation can never disagree about what a valid record looks like.

Two Supabase clients, clear roles

An anon-key client handles user-scoped reads under RLS; the service-role client is server-only for privileged operations and is never exposed to the browser or committed to source.

Configuration over hard-coding

Admin identity is environment-driven — a single GLOBAL_ADMIN_EMAIL and a comma-separated ACCOUNT_ADMIN_EMAILS allowlist — so authority can change without a code change, and a HASH_SALT keeps sensitive values from being stored in the clear.

Security posture
  • Row Level Security enabled on every table — branch and role isolation enforced by Postgres.
  • The Supabase service-role key is server-only, never exposed to the client or committed to git.
  • Layered admin roles: one global admin, an account-admin allowlist, and branch-scoped permissions.
  • Sensitive values are hashed with an environment-provided HASH_SALT; all configuration is environment-driven.
  • Audit logging records sensitive operations for an accountable change history.
Status & roadmap
Done

All three domains shipped — petroleum marketing, market operations, and rentals — on top of multi-branch tenancy, role-based access control, audit logging, and expense tracking, with RLS enforced across the schema.