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

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 require DATABASE_URL and 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.
TS
// 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 WHERE or ORDER BY column, add a matching CREATE INDEX IF NOT EXISTS in the relevant ensure*() 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
TS
// 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_thumb1200×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 for cover fits, 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.

TS
// 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_URL pointed at your Neon endpoint; the HTTP driver needs no pool tuning.
  • Add a CREATE INDEX IF NOT EXISTS for any new hot WHERE/ORDER BY column, then re-run npm 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.json uses bom1) to minimize DB latency.

© CreativeCape Solutions · creative-cape.com · support@creative-cape.com