Performance Guide
EduNest LMS is a single Next.js 16 application using the App Router with React 19 Server Components, talking to PostgreSQL over the serverless @neondatabase/serverless driver, with media on S3-compatible object storage (Cloudflare R2 by default). This guide describes the performance characteristics that actually exist in the shipped code and the practical knobs you can turn. There is no Redis, message queue, or external cache to operate — the architecture is intentionally simple.
Table of Contents
- Server Components & the App Router
- The Serverless Postgres Driver
- Database Indexes
- In-Process Caching
- Image Pipeline & CDN Delivery
- Static Generation
- Tuning Checklist
Server Components & the App Router
Pages live under src/app and render as React Server Components by default. Data is fetched on the server, directly adjacent to the database, and only the resulting HTML (plus minimal client JS) is sent to the browser. Interactive widgets opt in with "use client". This keeps client bundles small and avoids a separate API round-trip for initial render — the page and its data are produced in one server pass.
Route handlers under src/app/api/v1 serve JSON for client-side interactions and for native/mobile clients.
The Serverless Postgres Driver
Database access goes through @neondatabase/serverless (src/lib/db.ts), which speaks to Neon over HTTP rather than a long-lived TCP pool. This is what makes the app deploy cleanly to serverless/edge-style platforms where holding a persistent connection per instance is impractical.
Key implementation details that affect performance:
- Lazy client init. The Neon client is created on first query, not at module load, so
next build(which imports every route handler) does not requireDATABASE_URLand does not open connections at build time. - Tagged-template + parameterized queries. Callers use
sql\SELECT ... WHERE id = ${id}`orrawSql(text, params)`. Parameters are bound, so the driver and Postgres can plan efficiently.
// src/lib/db.ts — one lazily-initialised Neon client, reused per process
let _client = null;
function getClient() { if (_client) return _client; _client = neon(process.env.DATABASE_URL); return _client; }
Because the driver is HTTP-based, each query is an independent request. Favor a single query that returns what a page needs over many small sequential queries in a loop.
Database Indexes
Schema is created idempotently by ensure*() functions (CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS) — there is no separate migration tool. These functions also create indexes on the high-traffic lookup and foreign-key columns. Representative indexes from src/lib/lms-schema.ts:
| Index | Column(s) | Speeds up |
|---|---|---|
idx_courses_published |
status, published_at DESC |
Public course listing / ordering |
idx_courses_category |
category_id |
Browsing courses by category |
idx_courses_instructor |
instructor_id |
Instructor's own courses |
idx_sections_course |
course_id |
Loading a course's curriculum |
idx_lessons_section |
section_id |
Section → lessons |
idx_lessons_course |
course_id |
Course → lessons |
idx_enrollments_customer |
customer_id |
A learner's enrollments |
idx_bundles_instructor |
instructor_id |
Instructor bundles |
Schema files (lms-schema.ts, vendor-schema.ts, groups-schema.ts, calendar-schema.ts) define indexes across the data model. Because they use IF NOT EXISTS, re-running npm run db:init (or a normal boot path that calls the ensure* helpers) is safe and idempotent.
When you add a new high-frequency
WHEREorORDER BYcolumn, add a matchingCREATE INDEX IF NOT EXISTSin the relevantensure*()function so production picks it up automatically on next init.
In-Process Caching
There is no external cache server. The only caches in the code are small, in-process TTL caches:
| Cache | Location | TTL | Purpose |
|---|---|---|---|
| License status | src/lib/license/index.ts |
60 s | Avoids re-reading the app_license row and re-verifying the token on every hot public page render |
| Storage config / client | src/lib/r2.ts |
60 s | Caches the active storage connection and its S3 client so the bucket config isn't decrypted and re-instantiated on every upload/list |
// src/lib/r2.ts and src/lib/license/index.ts both use a 60s window
const TTL = 60_000;
These are per-process (each serverless instance keeps its own copy) and best-effort. With short TTLs, that is exactly the intent — no cross-instance coordination is required.
Image Pipeline & CDN Delivery
Uploaded images are processed server-side with sharp (src/lib/image-process.ts) and stored on S3-compatible object storage via src/lib/r2.ts.
What the pipeline does on upload:
- Resizes/crops the source into a set of preset sizes (e.g.
course_thumb→1200×900,400×300,160×120). - Converts to a chosen format (WebP by default; PNG/JPG selectable) at a configurable quality (default
82). - Honors EXIF orientation (
.rotate()), uses attention-based cropping forcoverfits, and never upscales logos/icons. - Drops the original by default (only the generated sizes are stored), unless the admin opts to keep it.
All of this is configurable from Admin → Settings → Theme Options → Images (stored in theme_settings): output format, quality, per-preset sizes, and whether to keep originals.
// src/lib/image-process.ts — defaults, overridable per preset from theme_settings
const quality = 82; // img_quality
const deleteOriginal = true; // img_delete_original
// course_thumb sizes: [[1200,900],[400,300],[160,120]]
Serving:
- Stored objects are served from the storage provider's public URL (Cloudflare R2's CDN edge by default; DigitalOcean Spaces, AWS S3, and Google Cloud Storage are also supported via the same S3 client).
- Next.js
<Image>is used in the UI for responsive sizing/lazy-loading of these CDN-hosted assets.
Smaller WebP variants plus CDN edge delivery mean browsers download appropriately-sized images close to the user. Keep the default WebP format and reasonable preset sizes unless a buyer specifically needs PNG/JPG.
Static Generation
The App Router statically renders pages where the data permits and uses dynamic rendering where it must. Dynamic content pages (e.g. blog and docs detail routes) use generateStaticParams to pre-render known slugs at build time, while data-driven admin and portal pages render dynamically per request. The cron and backup routes are explicitly dynamic (export const dynamic = "force-dynamic" / streamed responses) because they must run fresh.
You don't configure this — it follows from how each route fetches data. Pages that read per-request session or live DB state render dynamically; static-parameter content pages are pre-rendered.
Tuning Checklist
Database
- Keep
DATABASE_URLpointed at your Neon endpoint; the HTTP driver needs no pool tuning. - Add a
CREATE INDEX IF NOT EXISTSfor any new hotWHERE/ORDER BYcolumn, then re-runnpm run db:init. - Prefer one query that returns a page's data over sequential per-row queries.
- Select only the columns you render; avoid
SELECT *on wide tables in hot paths.
Assets
- Keep media on R2 (or another S3 provider) so it is served from a CDN, not your app server.
- Leave image output as WebP and keep "delete original" on unless originals are required.
- Tune per-preset sizes in Theme Options so you store only the dimensions the UI actually uses.
App
- Keep interactive code behind
"use client"; let everything else stay a Server Component. - Rely on the 60 s license/storage caches — don't add per-request re-verification.
- Run on a platform/region close to your Neon database (the bundled
vercel.jsonusesbom1) to minimize DB latency.
© CreativeCape Solutions · creative-cape.com · support@creative-cape.com