In the previous post we looked at the overall architecture and tech stack of eCourses, but a lot of the “real” experience of shipping a product lives inside the project structure.
In this post, I’ll walk through how I’ve organized the code so far, the patterns that make the app easier to scale, and why this structure fits so well for a community‑first LMS SaaS.
What eCourses Is (Quick Recap)
eCourses is a Learning Management System SaaS built for small communities and independent educators.
Right now the codebase is:
- Next.js 15 (App Router) + TypeScript + Prisma (PostgreSQL).
- Authentication with Clerk.
- Caching with Next.js fetch cache + Upstash Redis.
- UI built with Tailwind CSS, shadcn/ui, and dnd‑kit.
All of this is glued together by a project structure that tries to stay predictable, scoped, and easy to grow.
Folder Layout at a Glance
Here’s the core layout (slightly trimmed for readability):
src/
├── app/
│ ├── [communitySlug]/
│ │ ├── admin/
│ │ │ ├── courses/
│ │ │ │ ├── page.tsx # courses list with filters
│ │ │ │ ├── add/page.tsx # create course
│ │ │ │ ├── edit/page.tsx # edit course
│ │ │ │ └── manage/page.tsx # manage modules & lessons
│ │ │ ├── analytics/
│ │ │ └── ...
│ │ ├── courses/ # public course listing
│ │ ├── home/
│ │ └── sessions/
│ └── api/
│ ├── courses/
│ │ ├── route.ts # GET, POST
│ │ └── [courseId]/route.ts # GET, PATCH, DELETE
│ ├── modules/
│ │ ├── route.ts # GET, POST
│ │ ├── reorder/route.ts # PATCH
│ │ └── [moduleId]/route.ts # DELETE
│ ├── lessons/
│ │ ├── route.ts # POST
│ │ ├── reorder/route.ts # PATCH
│ │ └── [lessonId]/route.ts # DELETE
│ ├── members/
│ │ └── instructors/
│ │ ├── route.ts # GET non‑student members
│ │ └── [userId]/route.ts
│ └── upload-auth/route.ts # ImageKit auth
├── actions/
│ ├── courses.ts
│ ├── modules.ts
│ ├── lessons.ts
│ └── members.ts
├── components/
│ ├── courses/
│ ├── modules/
│ ├── lessons/
│ └── inputs/
├── hooks/
│ ├── use-course-form.ts
│ ├── use-course-filters.ts
│ ├── use-courses.ts
│ ├── use-instructor.ts
│ └── use-modules.ts
├── lib/
│ ├── api.ts # verifyApiRequest, generateSlug, validateWithRegex
│ ├── cache.ts # withCache, bustCache
│ ├── redis.ts # Upstash Redis client
│ └── prisma.ts
└── types/
├── course.ts # Prisma.CourseGetPayload types
└── module.ts # Prisma.ModuleGetPayload types
Why This Structure Works for an LMS
1. Community‑Scoped Routing
The key design choice is routing by communitySlug:
app/[communitySlug]/admin/courses/
app/[communitySlug]/courses/
This means:
- Every route is scoped to a community, which maps naturally to the
Community→Course→Module→Lessonmodel. - The same code can run multiple communities without needing separate deployments.
This is common in SaaS‑style LMS platforms, where you want to isolate data but reuse the same frontend logic.
2. Clean Separation: app / api / actions
I keep three layers for server‑side logic:
-
app/api/- Pure internal API routes.
- Each ends with
route.tsand follows REST‑ish patterns (e.g.,GET /courses,GET /courses/[courseId]). - All routes are protected via
x-api-secretandverifyApiRequest().
-
actions/- Server Actions that act as proxies to those API routes.
- They attach the secret server‑side so the API can be reused by both the frontend and a future mobile app.
-
lib/- Shared utilities:
-
api.ts→verifyApiRequest,generateSlug, validators. -
cache.ts→withCache,bustCache. -
prisma.ts→ a single Prisma client instance.
This keeps the business logic out of the route handlers and makes it easier to test, refactor, and reuse.
3. Domain‑Driven Components
Inside components/, I group by domain:
components/
├── courses/
├── modules/
├── lessons/
└── inputs/
This means:
- Anything that touches courses (cards, filters, forms) lives under
courses/. - Video cards, drag‑and‑drop lists, accordions, and lesson forms live in
lessons/andmodules/. - Shared form inputs live under
inputs/so they can be reused across multiple pages.
This pattern scales well as the LMS grows — adding new features like “assignments” or “quizzes” becomes a matter of adding a new domain folder instead of scattering logic everywhere.
4. Hooks: Collocating Logic with UI
The hooks/ folder is where I keep reusable logic that’s closely tied to the LMS model:
hooks/
├── use-course-form.ts # manage course form state + validation
├── use-course-filters.ts # sync URL query params with filter UI
├── use-courses.ts # fetch and cache course lists
├── use-instructor.ts # instructor‑related queries
└── use-modules.ts # module list + reordering helpers
Keeping these hooks together:
- Makes it easy to see what kinds of things the app can do just by scanning the folder.
- Lets components stay thin and focused on UI instead of business logic.
This approach is common in modern LMS‑style apps, where you end up with many similar “list + filter + form” flows.
5. Types and the Data Model
The types/ folder currently holds:
-
course.ts→ types derived fromPrisma.CourseGetPayload. -
module.ts→ types for modules and their nested relationships.
I keep these thin, auto‑generated, and strongly aligned with the database schema:
Community
└── Course (communityId, instructorId?)
└── Module (courseId, index: Float)
└── Lesson (moduleId, index: Float, type: VIDEO | SESSION)
This makes refactoring safer and keeps the TypeScript type system consistent with the underlying LMS model.
Things I Optimized for
When I was setting up this structure, I consciously optimized for:
-
Predictability
- New routes, components, and hooks follow a clear pattern so you can guess where to look.
-
Scalability
- Community‑scoped routing and modular components make it easy to add new features without breaking existing ones.
-
Separation of concerns
- API routes, server actions, UI components, and shared utilities each have their own place.
-
Developer experience
- TypeScript + Prisma + Zod + React Hook Form make the whole stack feel cohesive and type‑safe from top to bottom.
These are the same patterns I see in other production‑grade LMS‑style SaaS apps, where the complexity comes from the data model and workflows, not from a messy codebase.
How This Fits Into the Series
This post is the first deep dive into the codebase of eCourses. In future posts in this series, I’ll explore:
- The data model and why
Float index+ soft delete works so well for drag‑and‑drop. - The caching strategy (three‑layer cache, tag‑based invalidation).
- The admin‑panel UX patterns (optimistic UI, reordering, filters with URL state).
- How server actions as API proxies will support a future mobile app.
If you found this structure helpful, drop a comment or a reaction — I’ll keep using this series to walk through real‑world decisions as eCourses grows. And if you’re building your own LMS or SaaS, feel free to borrow or adapt any of these patterns (and tweak them to fit your own style).
Top comments (0)