← Back to work
↗ Live ↗ Code
Active · 2025 – 2026 Case study

OpenPath

A multi-tenant learning platform for grassroots education nonprofits. Each org gets its own isolated workspace: courses, files, assessments, messaging, and roles. Free.

20+
nonprofits hosted
0
rows lost in migration
1
codebase, many tenants
$0
cost to each org
Role · Lead engineer Stack · Next.js · TypeScript · Prisma · PostgreSQL
01 · The problem

Real programs, nowhere to host them.

Small education nonprofits run genuine curricula, cohorts, and assessments. Commercial LMS platforms price them out. Others assume a full-time administrator to configure them. The original build ran on a single Flask app. This app could not cleanly separate one organization's data from another's. Onboarding a new org became a risky, manual process.

The goal: one platform where a new nonprofit gets a fully isolated workspace in minutes, at no cost, without a dedicated admin.

02 · Architecture

One app, tenant-scoped from the database up.

Every request resolves to a tenant. Every query stays scoped to the same tenant. Prisma models carry a tenant key. Role-based access control gates every action. The Next.js app renders the right workspace per organization from one deployment.

Org A
Org B
Org C…
Next.js app
tenant resolver
+ RBAC middleware
Prisma
tenant-scoped queries
PostgreSQL
isolated tenant data

Every path from request to row carries the tenant. The system enforces isolation. Isolation is not assumed.

03 · The migration

Flask to a typed, tenant-safe stack, with zero data loss.

The rewrite moved OpenPath from Flask onto Next.js, TypeScript, Prisma, and PostgreSQL. The framework change was the easy part. The hard part: moving live nonprofit data across without dropping a single record or breaking a tenant boundary.

lib/tenant.ts
// Every query runs through a tenant-scoped Prisma client.
// A request can never read across tenant boundaries by accident.
export function forTenant(tenantId: string) {
  return prisma.$extends({
    query: {
      $allModels: {
        async $allOperations({ args, query }) {
          args.where = { ...args.where, tenantId }   // force scope
          return query(args)
        },
      },
    },
  })
}
 
// Resolved once per request from the session, then reused.
const db = forTenant(session.tenantId)
const courses = await db.course.findMany()   // only this org's

Illustrative excerpt of the tenant-scoping pattern. See the repo for the real implementation.

Isolation

Tenant key on every model. A scoped client makes cross-tenant reads structurally impossible.

Access control

Role-based permissions per workspace. Admins, instructors, and learners see only what they should.

Secrets

Encrypted credential storage keeps each org's integrations private and separate.

04 · Outcome

Twenty-plus orgs, one platform.

20+
Grassroots nonprofits on isolated tenants
Zero
Data loss migrating off Flask
Free
For every organization hosted
See the live platform, or read the code.

Running in production for StuImpact partners.

↗ Live ← All work