Architecting Nabodigonto: Scaling a Production Community Platform to a 100k+ LOC Monorepo
In late 2024, I set out to build the core digital platform for Nabodigonto Social Welfare Organization — a registered grassroots non-profit in Bangladesh operating community welfare programs, emergency blood donation coordination, student scholarships, and mutual aid funds. Following the initial architecture and launch of the platform, the executive board appointed me as IT Secretary to oversee the organization's technology roadmap, infrastructure, and ongoing engineering.
What began as an MVP web app eventually expanded into an ecosystem managing hundreds of active members, digital PVC-style membership IDs with dynamic verification, monthly dues tracking, and automated multi-channel notifications.
Today, Nabodigonto spans more than 100,000 lines of TypeScript across a Turborepo monorepo. This article is the first of a three-part technical series breaking down the real architectural decisions, operational trade-offs, and lessons learned from engineering this system for edge deployment.
1. The Trap of the All-in-One Monolith
When starting a community platform, the instinctive approach is to build a single Next.js monolith. In an early prototype, everything lived in one project:
- Public landing page, program galleries, and blood donor contacts.
- Authenticated member and administrative dashboards.
- PDF generation for member ID cards and financial donation receipts.
- Direct database connections and background notifications.
In practice, this created three immediate problems:
- Bundle Bloat for Public Visitors: Visitors arriving from mobile 3G/4G connections in rural Bangladesh just wanted to find an emergency blood donor or read an event report. Loading a heavy Next.js bundle bundled with Clerk authentication SDKs and administrative UI components resulted in unacceptable load times and high mobile data overhead.
- Blast Radius & Reliability: If a memory-heavy PDF generation job or a complex accounting query spiked memory or exceeded the CPU limit, it risked bringing down or throttling the entire web instance for public visitors.
- Coupled Dependencies: Updating an administrative package or changing an internal dashboard state risked regression bugs on public-facing informational pages.
To build a reliable platform that would last, I tore down the prototype and decoupled the entire codebase into a clean, modular Turborepo monorepo.
2. Monorepo Topology: Strict Fault Isolation
1nabodigonto/2├── apps/3│ ├── web-public/ # Vite + React 19 SPA (Auth-free, lightweight, offline fallback)4│ └── web-portal/ # Next.js 16 App Router + Clerk Auth (Member & Admin portal)5├── services/6│ ├── api/ # Hono API on Cloudflare Workers (RPC, D1/Prisma queries)7│ ├── media-service/ # Cloudflare Worker + R2 (PDF generation, font embedding)8│ └── notification-service/# Cloudflare Queues consumer (SMS, Email, WebPush, FCM)9├── packages/10│ ├── contracts/ # Shared Zod schemas & RPC route definitions11│ ├── db/ # Prisma schema & Cloudflare D1 driver adapter12│ ├── auth/ # Clerk JWT verification middleware for edge runtime13│ ├── api-client/ # Pre-typed Hono RPC client for frontend apps14│ └── ui/ # Shared Tailwind CSS v4 design system tokens & components15└── mobile/16 └── apps/17 └── nabodigonto-app/ # React Native companion app for field volunteersDecoupling web-public and web-portal
The most consequential design decision was splitting the frontend into two distinct applications:
-
apps/web-public(Vite + React 19 SPA):- Zero authentication dependencies. No Clerk SDK, no administrative state machines, and minimal runtime overhead.
- Deployed globally at the edge with immediate cacheability.
- Incorporates an offline JSON snapshot of verified emergency blood donors and executive contacts, cached in the browser so users can access critical contact information even when their mobile internet drops out.
-
apps/web-portal(Next.js 16 App Router):- Dedicated entirely to authenticated experiences: member profiles, monthly dues tracking, dynamic 3D member ID cards, and the administrative accountability suite.
- Uses Clerk for authentication with session tokens verified at the edge by
services/api.
Because the public site and portal are independent apps, a deployment or issue in the portal has zero impact on the public site's availability.
3. Type-Safe Edge APIs with Hono RPC & Shared Contracts
Rather than writing traditional REST controllers with hand-rolled fetch calls or maintaining OpenAPI documentation that quickly drifts from reality, the backend is built on Hono running on Cloudflare Workers, coupled with packages/contracts.
In packages/contracts, we define domain validation schemas using Zod:
1// packages/contracts/src/dues.ts2import { z } from "zod"34export const RecordDuesPaymentSchema = z.object({5 memberId: z.string().cuid(),6 amount: z.number().positive("Amount must be greater than zero"),7 monthPaid: z.string().regex(/^\d{4}-\d{2}$/, "Must be in YYYY-MM format"),8 paymentMethod: z.enum(["HAND_CASH", "BKASH", "NAGAD", "BANK_TRANSFER"]),9 notes: z.string().max(500).optional(),10})1112export type RecordDuesPaymentInput = z.infer<typeof RecordDuesPaymentSchema>1314export const RegisterMemberSchema = z.object({15 fullName: z.string().min(3).max(100),16 phoneNumber: z.string().regex(/^(\+8801|01)[3-9]\d{8}$/, "Valid BD phone number required"),17 bloodGroup: z.enum(["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"]),18 role: z.enum(["GENERAL_MEMBER", "VOLUNTEER", "DONOR"]).default("GENERAL_MEMBER"),19})2021export type RegisterMemberInput = z.infer<typeof RegisterMemberSchema>In services/api, the Hono router mounts these contracts directly with input validators:
1// services/api/src/routes/dues.ts2import { Hono } from "hono"3import { zValidator } from "@hono/zod-validator"4import { RecordDuesPaymentSchema } from "@nabodigonto/contracts"5import { requireOfficerAuth } from "../middleware/auth"67export const duesRouter = new Hono()8 .post(9 "/collect",10 requireOfficerAuth,11 zValidator("json", RecordDuesPaymentSchema),12 async (c) => {13 const input = c.req.valid("json")14 const officer = c.get("user")15 16 const result = await c.env.DB_SERVICE.recordDuesPaymentAtomic({17 ...input,18 receivedById: officer.id,19 })2021 return c.json(result, 201)22 }23 )In the frontend applications, consuming this endpoint requires no string URLs or manual type casting:
1// apps/web-portal/src/lib/api.ts2import { hc } from "hono/client"3import type { AppType } from "@nabodigonto/api"45export const api = hc<AppType>(process.env.NEXT_PUBLIC_API_URL!)67// Inside a React component or action:8const res = await api.dues.collect.$post({9 json: {10 memberId: "cuid_9872134",11 amount: 500,12 monthPaid: "2026-08",13 paymentMethod: "BKASH",14 notes: "August membership dues.",15 },16})1718if (res.ok) {19 const data = await res.json()20 // data is fully typed according to the backend contract return type21}If a backend route changes its parameter names, response structure, or validation rules, TypeScript flags the compile-time error across all consuming apps during turbo build.
4. Operational Boundaries: Independent Edge Services
Instead of packing all operational tasks into one monolithic worker, background and specialized workloads are broken into dedicated workers:
services/media-service: An isolated worker responsible for generating PDF membership cards and monthly financial statements. It has its own Cloudflare R2 bucket binding and usespdf-libwith@pdf-lib/fontkitto assemble vector PDFs in memory. By keeping this off the main API worker, CPU-intensive font rendering cannot starve live API requests of execution time.services/notification-service: A Cloudflare Queues consumer. When financial receipts, dues reminders, or emergency blood alerts are created, the API worker pushes an event payload to the queue. The notification worker processes them asynchronously, handling retries and rate-limiting across SMS gateways (OneCodeSoft) and email (Resend).
5. Key Architecture Lessons
- Decouple Public Pages from Auth Runtimes: The highest traffic part of a non-profit site is often public-facing (events, emergency donor lookups, donation guidelines). Keeping authentication SDKs out of the public bundle keeps performance fast and resilient to auth provider outages.
- Contracts First, Implementation Second: In a monorepo with multiple apps and services, maintaining shared validation schemas in a separate package (
packages/contracts) eliminates an entire category of client-server serialization bugs. - Small Is Safe: Distributing workloads across specialized serverless workers ensures that failures remain localized and operational costs remain near zero.
In Part 2, we will dive into data storage: how we modeled the relational database on Cloudflare D1, handled atomic multi-table transactions with D1 batch operations, and rendered complex Bengali Unicode fonts in edge PDF workers.