Technical Documentation
This document describes the technical internals of EduNest LMS — a complete Learning Management System built as a single Next.js 16 App Router application with React 19, TypeScript, Tailwind CSS 3, and serverless PostgreSQL (Neon). It is written for developers extending or maintaining the platform and is grounded in the actual codebase (route handlers, the self-healing schema layer, the core/product split, and the auth/storage helpers).
Architecture
Unlike a split client/server stack, EduNest is one deployable Next.js application.
The same project renders the public website, the admin panel, the learner portal, and
the instructor portal, and also exposes the HTTP API through route handlers
(route.ts files under src/app/api). There is no separate API server, no ORM, and
no message broker.
| Layer | Implementation |
|---|---|
| UI + Pages | Next.js App Router (server & client components) under src/app |
| API | Route handlers (route.ts) under src/app/api/v1/* — 256 handlers |
| Data access | Raw SQL via tagged templates (src/lib/db.ts) over @neondatabase/serverless |
| Schema | Self-healing ensure*() functions (src/lib/*-schema.ts) — no migration tool |
| Auth | Custom JWT with jose (admin) + jsonwebtoken/jose (learner/instructor) |
| Storage | Cloudflare R2 / S3-compatible via the AWS S3 SDK (src/lib/r2.ts) |
| Secrets | AES-256-GCM at rest (src/lib/secrets.ts) |
| Extensibility | Core/product layer split (src/core + src/product) with 93 built-in add-ons |
Route Groups
src/app is organised into App Router route groups (parenthesised folders do not
appear in the URL):
| Group / segment | URL | Audience |
|---|---|---|
(site) |
/, /courses, /blogs, /checkout, … |
Public storefront + website |
(auth) |
/login, /register, /forgot-password, … |
Learner/instructor account auth |
admin/(panel) |
/admin/* |
Admin dashboard (behind admin login) |
learner |
/learner/* |
Enrolled-learner portal |
instructor |
/instructor/* |
Instructor portal |
learn |
/learn/[slug] |
Course player (lesson viewer) |
install |
/install |
First-run setup wizard |
api |
/api/v1/*, /api/install, /api/license |
HTTP API (route handlers) |
docs, api-docs, files |
/docs, /api-docs, /files |
In-app docs, OpenAPI viewer, file proxy |
Request Lifecycle
A typical API request flows like this:
Browser (site / admin / learner / instructor)
│ fetch /api/v1/<resource>
▼
Next.js Route Handler (src/app/api/v1/<resource>/route.ts)
├─ ensure<Table>() # idempotent CREATE TABLE IF NOT EXISTS
├─ auth check # getSession() (admin) / getCustomerId() (portal)
├─ requirePermission(...) # admin routes only, per-route RBAC
├─ business logic + raw SQL # sql`SELECT … FROM …` (src/lib/db.ts)
└─ NextResponse.json(...) # JSON response
▼
PostgreSQL (Neon, serverless driver over HTTPS)
+ Cloudflare R2 (uploads) + License server + Integration providers
Because each handler calls its ensure*() schema function before querying, a fresh
database never throws relation does not exist — the schema heals itself on first use.
Cross-cutting Concerns
- Schema healing — every table is defined by an
ensure*()function that runsCREATE TABLE IF NOT EXISTSplusALTER TABLE … ADD COLUMN IF NOT EXISTSfor later additions. An in-memory guard runs each one only once per server process. - Secrets — third-party credentials (SMTP, payment keys, storage keys) are stored
encrypted with AES-256-GCM (
encryptSecret/decryptJsoninsrc/lib/secrets.ts) and never returned to the client in plaintext. - Storage — uploads go to Cloudflare R2 (or any S3-compatible bucket) through the
AWS S3 SDK; images can be resized with
sharpbefore upload. - Public IDs — admin-facing entities get a non-sequential
public_id(cuid-like) viaensurePublicId()so URLs don't leak integer counters. - Extensibility — features and integration providers are add-ons under
src/product, aggregated into generated indexes and surfaced through thesrc/coreregistries (see Core / Product Layer Split).
Core / Product Layer Split
EduNest separates stable framework code from swappable features and providers so the same engine can power many products. Two top-level folders cooperate:
src/core/ # contracts + registries (stable, shipped with every build)
src/product/ # add-ons (features + integration providers) + GENERATED indexes
src/coredefines the contracts and the registries that the app reads at runtime:core/channels/registry.ts→CHANNELS— the catalog of integration channels (email, payments, storage, analytics, video, calendar, captcha, sms, chat…), each with its free built-in providers, merged with provider add-ons.core/plugins/registry.ts→FEATURES/FeaturePlugin— the feature-plugin contract (nav items, admin/learner/public pages, API handlers, lifecycle hooks likecourse.completed,order.paid,ensureSchema()).core/payments/registry.ts→PAYMENT_DRIVERS— checkout drivers, with built-in free drivers (cod,bank,paypal) plus premium gateway add-ons.core/adapters/{client,server}.ts→clientAdapters()/serverAdapters()— runtime dispatch that resolves a provider's implementation by kind + provider.
src/product/addons/<id>/holds the 93 built-in add-ons. Each has anaddon.jsonmanifest plus anindex.tsthat exports achannel, aplugin, adriver, and/or client/server adapters. Feature add-ons (e.g.certificates,gamification,exams,assignments) own their database tables through anensureSchema()and ship their own React pages and API handlers.Generated indexes are produced by
scripts/bundle-addons.mjsand must not be edited by hand:src/product/addons/index.ts(ADDON_CHANNELS),src/product/features/index.ts(ADDON_FEATURES),src/product/adapters-server/index.ts, andsrc/product/adapters-client/index.ts. The core registries import these to merge add-ons in at build time.
This means new features and new integration providers are added by dropping an add-on
folder (or importing its zip from docs/add-ons) and regenerating the indexes — core
code is not touched.
Data Access Pattern
There is no ORM and no migration framework. Data access is raw SQL through a thin Neon wrapper.
src/lib/db.ts lazily initialises a Neon client and re-exposes the tagged-template
API so callers keep writing sql\…``:
import sql, { rawSql } from "@/lib/db";
const rows = await sql`SELECT id, title FROM courses WHERE status = ${"published"}`;
// dynamic/parameterised form:
const dyn = await rawSql("SELECT 1 FROM courses WHERE slug = $1 LIMIT 1", [slug]);
Schema is defined in code, not in .sql files. Each domain area has a self-healing
ensure*() function:
// src/lib/lms-schema.ts (abridged)
let coursesReady = false;
export async function ensureCourses(): Promise<void> {
if (coursesReady) return; // in-memory guard → runs once per process
await ensureInstructors();
await ensureCourseCategories();
await sql`CREATE TABLE IF NOT EXISTS courses ( … )`;
await sql`ALTER TABLE courses ADD COLUMN IF NOT EXISTS tags JSONB DEFAULT '[]'::jsonb`;
await sql`CREATE INDEX IF NOT EXISTS idx_courses_published ON courses(status, published_at DESC)`;
coursesReady = true;
}
Conventions throughout the schema:
SERIALinteger primary keys;snake_casecolumns.- Money stored as integer cents (
*_cents) with a siblingcurrencycolumn. - Arrays/objects stored as
JSONB. - Common columns
is_active,sort_order,created_at,updated_at. - Idempotent
CREATE TABLE IF NOT EXISTS+ALTER TABLE … ADD COLUMN IF NOT EXISTS.
The schema is split across files: src/lib/vendor-schema.ts (commerce/store/users/
settings), src/lib/lms-schema.ts (courses, lessons, enrollments, instructors,
commerce), plus scripts/init-db.ts (CMS tables), and per-add-on schema.ts files.
The init scripts (scripts/init-db.ts, init-vendor.ts, init-lms.ts) simply call
the same ensure*() functions in bulk so a fresh database is fully provisioned.
Authentication & Authorization
EduNest has two independent identities, both custom JWT, no refresh tokens.
Admin
src/lib/auth.ts issues an HS256 JWT signed with JWT_SECRET, stored in the
admin_token httpOnly cookie. Admins live in the users table (email + bcrypt
password, optional role_id → roles).
export interface JWTPayload { id: number; email: string; name: string; }
export async function signToken(p: JWTPayload): Promise<string> { … } // jose, HS256
export async function getSession(): Promise<JWTPayload | null> { … } // reads cookie
- Lifetime:
JWT_EXPIRES_IN(default7d). - Admin API routes are gated per route with permission checks (RBAC via the
roles.permissionsJSONB list +permissionscatalog). There is no global middleware guard.
Learner / Instructor (Portal)
src/lib/customer-auth.ts issues a separate JWT (payload CustomerPayload with id,
email, name, accountType), delivered as the member_session httpOnly cookie, and
also accepted as an Authorization: Bearer <jwt> header for mobile clients. Accounts
live in the customers table; account_type is 'student' or 'instructor'.
- Passwords are bcrypt-hashed (
password_hash). - Email/phone verification and password reset use bcrypt-hashed OTP codes
(
otp_code,otp_expires_at,otp_purpose). getCustomerId()resolves the current learner/instructor (bearer first, then cookie).
Storage & Secrets
src/lib/r2.tswraps the AWS S3 SDK against any S3-compatible bucket. Active storage config is read from theintegration_connectionstable (channelSTORAGE, providersr2/aws_s3/gcs/do_spaces), falling back toR2_*env vars. It exposes helpers such asputObject,getUploadUrl(presigned PUT),deleteByUrl,listAllObjects, andgetPublicBaseUrl.src/lib/secrets.tsencrypts integration credentials with AES-256-GCM using a key derived (SHA-256) fromSECRET_KEY(falling back toJWT_SECRET). Encrypted values are taggedenc:v1:<base64>. Exposed viaencryptSecret,decryptSecret,encryptJson,decryptJson.
Licensing
The domain license is verified offline with a bundled RS256 public key. A single
app_license row (JSONB blob) stores the activation record. src/lib/license
provides activateLicense, recheckLicense, getLicenseStatus, and isLicensed. A
daily background heartbeat re-validates against LICENSE_SERVER_URL with a grace
period, and localhost / private-LAN hosts bypass licensing for development. See the
License Guide for details.
Key Directories
| Path | Purpose |
|---|---|
src/app |
App Router pages + route handlers (the API) |
src/app/api/v1 |
REST API resource handlers (route.ts) |
src/lib |
DB client, schema (*-schema.ts), auth, r2, secrets, queries, helpers |
src/core |
Reusable contracts + registries (channels, plugins, payments, adapters) |
src/product |
93 add-ons + generated aggregation indexes |
src/components |
Shared UI (admin, learn, dashboard, form, ui, data-table, …) |
src/views |
Page-level view components for the public site |
scripts |
DB init + seed scripts, add-on bundler/catalog generators |
docs |
Buyer docs + add-on packages (docs/add-ons) |
Configuration
Runtime configuration is environment-driven, with most behaviour also editable at
runtime through admin-managed settings tables (app_settings, theme_settings,
integration_connections).
| Variable | Used by | Purpose |
|---|---|---|
DATABASE_URL |
src/lib/db.ts |
Neon PostgreSQL connection string |
JWT_SECRET / JWT_EXPIRES_IN |
src/lib/auth.ts, customer-auth.ts |
Token signing & lifetime |
SECRET_KEY |
src/lib/secrets.ts |
AES-256-GCM key for encrypting stored secrets |
R2_* |
src/lib/r2.ts |
Fallback storage credentials (DB config preferred) |
LICENSE_SERVER_URL |
src/lib/license |
License activation / heartbeat endpoint |
© CreativeCape Solutions · creative-cape.com · support@creative-cape.com