DEV Community

Ateeb Hussain
Ateeb Hussain

Posted on

Project Structure of eCourses: How I Organized My LMS SaaS Codebase

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

This means:

  • Every route is scoped to a community, which maps naturally to the CommunityCourseModuleLesson model.
  • 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.ts and follows REST‑ish patterns (e.g., GET /courses, GET /courses/[courseId]).
    • All routes are protected via x-api-secret and verifyApiRequest().
  • 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.tsverifyApiRequest, generateSlug, validators.
    • cache.tswithCache, 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/
Enter fullscreen mode Exit fullscreen mode

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/ and modules/.
  • 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
Enter fullscreen mode Exit fullscreen mode

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 from Prisma.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)
Enter fullscreen mode Exit fullscreen mode

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)