Developer Guide
This guide explains how to work in the EduNest LMS codebase the right way. EduNest is a single Next.js 16 (App Router) application written in TypeScript and React 19, talking directly to PostgreSQL over @neondatabase/serverless (raw parameterized SQL — no ORM), with assets on Cloudflare R2. Follow the conventions below to keep the product consistent and resale-ready.
Tech Stack
| Concern | Choice |
|---|---|
| Framework | Next.js 16 (App Router) + React 19 |
| Language | TypeScript 5 |
| Database | PostgreSQL via @neondatabase/serverless (raw SQL) |
| Auth | Custom JWT (jose / jsonwebtoken) + bcryptjs, HttpOnly cookie |
| Secrets | AES-256-GCM (Node crypto), key from SECRET_KEY |
| Storage | Cloudflare R2 via @aws-sdk/client-s3 |
| Styling | Tailwind CSS 3 + lucide-react |
| Images | sharp (resize/convert to WebP) |
Local Setup
npm install
cp .env.example .env # then fill in DATABASE_URL, JWT_SECRET, etc.
npm run db:init # bootstrap auth + vendor + LMS schema
npm run dev # start the dev server (Turbopack)
The dev server runs at http://localhost:3000. On localhost the license is bypassed, so the admin panel opens without activation.
npm scripts
| Script | Purpose |
|---|---|
dev |
Start the Next.js dev server (Turbopack) |
build |
Production build |
start |
Run the production server |
lint |
ESLint |
type-check |
TypeScript check (no emit) |
db:init |
Initialize the full schema (auth + vendor + LMS) |
db:init:vendor |
Initialize the vendor/settings schema only |
db:init:lms |
Initialize the LMS schema (courses, lessons, enrollments) |
db:seed:core |
Seed core data |
db:reset |
Reset the database to the template state |
addons:bundle |
Regenerate the add-on aggregator indexes (npm run addons:bundle) |
addons:catalog |
Regenerate docs/add-ons/catalog.json |
Project Conventions
- TypeScript everywhere; the
@/path alias maps tosrc/— prefer it over deep relative paths. - Data access goes through the shared
sqlclient insrc/lib/db.ts(raw parameterized SQL). No ORM. - Every API route checks permissions with
requirePermission(action, resource)before doing work. - Tables are created lazily by idempotent
ensure*()functions called at the top of a route — there is no migration runner. - Secrets are encrypted with
encryptSecret()before storage and masked in API responses; never return a plaintext key. - Server-only modules (
db,secrets, anything importing Nodecrypto) must never be imported into client components.
The core / product Layer Split
EduNest is built to power many products from one architecture, so code is split into two layers:
src/core/ Reusable integration infrastructure (channels, adapters, plugins)
src/product/ Product-specific identity, navigation, monetization, and add-ons
src/lib/ Shared utilities (db, auth, secrets, schema bootstrap, helpers)
src/app/ Next.js routes (storefront, /admin, /learner, /instructor, /learn, /api)
src/core/holds the technical machinery: the channels registry and provider types (channels/), runtime adapters (adapters/), and the feature-plugin system (plugins/types.ts,plugins/state.ts,plugins/registry.ts,plugins/hooks.ts).src/product/holds what makes this product itself:product.ts(PRODUCT_ID = "edunest-lms"), the data-driven navs (admin-nav.ts,learner-nav.ts,instructor-nav.ts), checkout/payout config, andaddons/— the 93 built-in add-ons.
Add-ons register into core through generated aggregator indexes (ADDON_CHANNELS, ADDON_FEATURES, server/client adapters). See the Add-on and Plugin Development guides.
Data Access (raw SQL + ensure-schema)
The single database client lives in src/lib/db.ts:
import sql, { rawSql } from "@/lib/db";
// Tagged-template form (preferred) — values are parameterized, not interpolated.
const rows = await sql`SELECT * FROM courses WHERE id = ${courseId}`;
// Method form for dynamic SQL.
const r = await sql.query("SELECT * FROM courses WHERE id = $1", [courseId]);
// rawSql helper for parameterized non-template queries.
const list = await rawSql("SELECT * FROM courses WHERE status = $1", ["published"]);
type Row = Record<string, unknown> is also exported. The client initializes lazily on first use so next build doesn't fail when DATABASE_URL is absent.
The ensure-schema pattern
There is no migration tool. Each table is created by an idempotent, guarded ensure*() function that's called at the start of any route that touches it. Example from src/lib/lms-schema.ts:
let instructorsReady = false;
export async function ensureInstructors(): Promise<void> {
if (instructorsReady) return;
await ensureCustomers(); // dependencies first
await sql`
CREATE TABLE IF NOT EXISTS instructors (
id SERIAL PRIMARY KEY,
customer_id INT UNIQUE NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
status VARCHAR(20) DEFAULT 'pending',
revenue_share_pct NUMERIC DEFAULT 70,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`;
instructorsReady = true;
}
The boolean flag makes the call cheap after the first run; CREATE TABLE IF NOT EXISTS and ADD COLUMN IF NOT EXISTS make it safe to call repeatedly and on older databases. The auth schema (src/lib/auth-schema.ts) follows the same pattern and seeds the first admin user from ADMIN_EMAIL / ADMIN_PASSWORD.
Auth Helpers & Sessions
JWTs are signed and verified in src/lib/auth.ts using jose (HS256) with the secret from JWT_SECRET, stored in the HttpOnly cookie admin_token:
export async function signToken(payload: JWTPayload): Promise<string>; // 7d default
export async function verifyToken(token: string): Promise<JWTPayload | null>;
export async function getSession(): Promise<JWTPayload | null>; // reads the cookie
Authorization is enforced per route by requirePermission() in src/lib/require-permission.ts:
export type PermAction = "read" | "create" | "update" | "delete";
export async function requirePermission(
action: PermAction, resource: string,
): Promise<Response | null>; // returns a 403 Response, or null when allowed
Super roles (e.g. VENDOR_ADMIN, SUPERADMIN) bypass checks; everyone else is matched against their role's permissions array of "<resource>:<action>" strings. Authorized mutations also emit an admin.action core event so an audit plugin can log them — without ever blocking the route. Learner-facing routes resolve the current customer via getCustomerId() (src/lib/customer-auth.ts).
Adding an API Route
API routes live under src/app/api/... and export GET / POST / PUT / DELETE handlers. Follow this order: permission → ensure schema → query → mask secrets → respond.
import { NextRequest } from "next/server";
import sql from "@/lib/db";
import { requirePermission } from "@/lib/require-permission";
import { ensureCourses } from "@/lib/lms-schema";
import { ok, serverErr } from "@/lib/api-helpers";
export async function GET() {
const denied = await requirePermission("read", "courses");
if (denied) return denied;
try {
await ensureCourses();
const rows = await sql`SELECT * FROM courses ORDER BY created_at DESC`;
return ok(rows);
} catch (e) {
return serverErr(e);
}
}
src/lib/api-helpers.ts provides ok(), err(message, status), and serverErr(e) for consistent JSON responses.
Secrets / Encryption
src/lib/secrets.ts provides AES-256-GCM helpers. The key is the SHA-256 hash of SECRET_KEY (falling back to JWT_SECRET):
encryptSecret(plain: string): string; // "enc:v1:" + base64(iv || tag || ciphertext)
decryptSecret(value: string): string;
encryptJson(obj: unknown): string;
decryptJson<T>(value: unknown): T;
When a settings field holds a secret (an API key, a payment secret), encrypt it on write and return a mask (••••••••) on read so plaintext never leaves the server.
Environment Variables
| Variable | Purpose |
|---|---|
DATABASE_URL |
Neon PostgreSQL connection string (required) |
JWT_SECRET |
HMAC secret for signing JWTs (required) |
JWT_EXPIRES_IN |
Token lifetime (default 7d) |
SECRET_KEY |
Source for the AES-256-GCM key (falls back to JWT_SECRET) |
NEXT_PUBLIC_SITE_URL |
Public base URL |
ADMIN_EMAIL / ADMIN_PASSWORD |
First-run admin bootstrap |
NODE_ENV |
production enables secure cookies |
LICENSE_SERVER_URL / LICENSE_PUBLIC_KEY |
License verification (optional overrides) |
NOTIFY_EMAIL / NOTIFY_EMAIL sender |
Notification sender |
Provider credentials (Cloudflare R2, Stripe, SendGrid, etc.) are not kept in .env — admins enter them in Settings → Integrations and they are stored encrypted in the database.
© CreativeCape Solutions · creative-cape.com · support@creative-cape.com