From 2af4a53130bd67cc80b2dd695e5ec644ea1d6847 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Fri, 13 Feb 2026 23:02:44 +0530 Subject: [PATCH 1/8] Claude 4.6 Report and Fixes #1 and #2 --- apps/web/PERFORMANCE_REPORT.md | 331 ++++++++++++++++++++++++++++ apps/web/app/verify-domain/route.ts | 37 +--- apps/web/lib/domain-cache.ts | 48 ++++ apps/web/next-env.d.ts | 1 + apps/web/ui-lib/utils.ts | 154 ++++++------- 5 files changed, 467 insertions(+), 104 deletions(-) create mode 100644 apps/web/PERFORMANCE_REPORT.md create mode 100644 apps/web/lib/domain-cache.ts diff --git a/apps/web/PERFORMANCE_REPORT.md b/apps/web/PERFORMANCE_REPORT.md new file mode 100644 index 000000000..b951b6145 --- /dev/null +++ b/apps/web/PERFORMANCE_REPORT.md @@ -0,0 +1,331 @@ +# Public Pages Performance Optimization Report + +## Executive Summary + +After auditing the `apps/web` codebase, I found that the public page rendering pipeline has **severe data-fetching redundancy** as the dominant bottleneck. A single public page load triggers **8–12 HTTP round-trips** to the same GraphQL API, most of which fetch identical data. This is the single highest-impact area. Below are optimizations ranked by **decreasing ROI**. + +--- + +## 🔴 1. Eliminate Redundant Data Fetching with `React.cache()` (Highest ROI) + +**Impact: ~60–70% reduction in server-side render time** +**Effort: Small (1-2 hours)** + +### The Problem + +Every public page triggers the following call chain, where each level independently fetches the same data: + +```mermaid +graph TD + A["Root layout.tsx
getSiteInfo() + getFullSiteSetup()"] --> B["(with-contexts)/layout.tsx
getFullSiteSetup()"] + B --> C["(with-layout)/layout.tsx
getFullSiteSetup()"] + C --> D["page.tsx
getFullSiteSetup() + getPage()"] + C --> E["generateMetadata()
getFullSiteSetup() + getPage()"] +``` + +`getFullSiteSetup()` itself makes **2 sequential HTTP requests** (first [getSiteInfo](file:///Users/rajat/dev/projects/courselit/apps/web/ui-lib/utils.ts#L129-L170), then [getTheme + getPage + getFeatures](file:///Users/rajat/dev/projects/courselit/apps/web/ui-lib/utils.ts#L184-L217)). This means: + +| Layer | Calls to `getFullSiteSetup` | HTTP Requests | +| ------------------------------------------------------------------------------------------------------------------------------------------ | :-------------------------: | :-----------: | +| [Root layout.tsx](file:///Users/rajat/dev/projects/courselit/apps/web/app/layout.tsx#L52) | 1 | 2 | +| [Root generateMetadata](file:///Users/rajat/dev/projects/courselit/apps/web/app/layout.tsx#L16) | 1 (getSiteInfo) | 1 | +| [(with-contexts)/layout.tsx]() | 1 | 2 | +| [(with-layout)/layout.tsx]() | 1 | 2 | +| [p/[id]/page.tsx]() | 1 | 2 | +| [p/[id]/generateMetadata]() | 1 | 2 | +| **Total per page** | **6** | **~11** | + +### The Fix + +Wrap the data-fetching functions in `React.cache()`. React automatically deduplicates calls to cached functions **within a single server render request**. + +```typescript +// ui-lib/utils.ts +import { cache } from "react"; + +export const getFullSiteSetup = cache(async (backend: string, id?: string) => { + // ... existing implementation +}); + +export const getPage = cache(async (backend: string, id?: string) => { + // ... existing implementation +}); + +export const getSiteInfo = cache(async (backend: string) => { + // ... existing implementation +}); +``` + +This **single change** reduces ~11 HTTP requests per page down to **~3** (one for `getSiteInfo`, one for the batched GraphQL query, and one for `getPage` if applicable). Zero architectural change needed — just wrap existing functions. + +--- + +## 🔴 2. Cache `verify-domain` Domain Lookup (High ROI) + +**Impact: ~50–150ms saved per request** +**Effort: Small** + +### The Problem + +The [proxy function in proxy.ts](file:///Users/rajat/dev/projects/courselit/apps/web/proxy.ts#L5-L89) runs on **every single request** and fetches `/verify-domain`. While the subscription check is already smart (it only calls the external service once per 24 hours via `checkSubscriptionStatusAfter`), the [verify-domain route](file:///Users/rajat/dev/projects/courselit/apps/web/app/verify-domain/route.ts#L40-L198) still does this on **every request**: + +1. `await connectToDatabase()` — connection setup overhead +2. `await getDomain(host)` — MongoDB query (`DomainModel.findOne`) + +Domain data (name, domainId, logo, title) changes extremely rarely — only when an admin updates site settings. + +### The Fix + +Cache the `getDomain()` result with a short in-memory TTL: + +```typescript +// lib/domain-cache.ts +const domainCache = new Map(); +const TTL = 60_000; // 60 seconds + +export async function getCachedDomain(host: string) { + const cached = domainCache.get(host); + if (cached && cached.expiresAt > Date.now()) { + return cached.data; + } + + const domain = await getDomain(host); + domainCache.set(host, { data: domain, expiresAt: Date.now() + TTL }); + return domain; +} +``` + +For multi-server environments, use **Redis** to cache the domain lookup result keyed by hostname with a 60s TTL. + +--- + +## 🟡 3. Direct Database Queries Instead of HTTP Self-Fetch (High ROI) + +**Impact: ~50–100ms saved per internal API call** +**Effort: Medium** + +### The Problem + +Server Components fetch data by making **HTTP requests to themselves** via the GraphQL API: + +```typescript +// ui-lib/utils.ts - Server component making an HTTP request to itself +const fetch = new FetchBuilder() + .setUrl(`${backend}/api/graph`) // self-referencing HTTP call! + .setPayload({ query }) + .build(); +``` + +Each call to `/api/graph` goes through: + +1. Network round-trip (even if localhost, ~5-15ms) +2. Next.js request handling + middleware re-execution +3. [GraphQL route handler](file:///Users/rajat/dev/projects/courselit/apps/web/app/api/graph/route.ts#L22-L84) does another `DomainModel.findOne()` + `auth.api.getSession()` + `User.findOne()` +4. Then the actual resolver logic runs + +### The Fix + +**For public pages** (no auth needed), call the database models directly from Server Components: + +```typescript +// lib/public-queries.ts +import PageModel from "@models/Page"; +import DomainModel from "@models/Domain"; +import { cache } from "react"; + +export const getPublicPage = cache(async (domainId: string, pageId: string) => { + return PageModel.findOne( + { pageId, domain: domainId }, + { + layout: 1, + title: 1, + description: 1, + socialImage: 1, + robotsAllowed: 1, + }, + ).lean(); +}); + +export const getPublicSiteSetup = cache(async (domainId: string) => { + const [domain, theme] = await Promise.all([ + DomainModel.findById(domainId, { + /* projections */ + }).lean(), + ThemeModel.findOne({ domain: domainId, active: true }).lean(), + ]); + return { settings: domain?.settings, theme, features: domain?.features }; +}); +``` + +This eliminates the HTTP round-trip overhead and the redundant auth/domain resolution in the GraphQL handler. + +--- + +## 🟡 4. Redis Caching Layer for Tenant Data (High ROI for Multi-Tenant) + +**Impact: ~80–95% reduction in MongoDB load for public pages** +**Effort: Medium** + +### The Problem + +In a multi-tenant system, site settings, themes, and page layouts rarely change (only when an admin publishes). But every visitor request hits MongoDB. + +### The Fix + +Add a Redis cache for tenant-scoped, infrequently changing data: + +```typescript +// lib/cache.ts +import { createClient } from "redis"; + +const redis = createClient({ url: process.env.REDIS_URL }); +const DEFAULT_TTL = 300; // 5 minutes + +export async function getCached( + key: string, + fetcher: () => Promise, + ttl = DEFAULT_TTL, +): Promise { + const cached = await redis.get(key); + if (cached) return JSON.parse(cached); + + const data = await fetcher(); + await redis.set(key, JSON.stringify(data), { EX: ttl }); + return data; +} +``` + +**Cache key structure** (tenant-scoped): + +``` +site:{domainId}:settings → SiteInfo (TTL: 5 min) +site:{domainId}:theme → Theme (TTL: 5 min) +site:{domainId}:page:{pageId} → Page (TTL: 5 min) +site:{domainId}:features → string[] (TTL: 5 min) +``` + +**Cache invalidation**: Bust the cache in the `updateSiteInfo`, `publish` (page), and `publishTheme` mutation resolvers: + +```typescript +// In graphql/pages/logic.ts publish() +await redis.del(`site:${ctx.subdomain._id}:page:${pageId}`); + +// In graphql/settings/logic.ts updateSiteInfo() +await redis.del(`site:${ctx.subdomain._id}:settings`); +``` + +--- + +## 🟡 5. Convert Client Components to Server Components (Medium ROI) + +**Impact: Significantly smaller JS bundles sent to browser** +**Effort: Medium-High** + +### The Problem + +Several public-facing pages that could be Server Components are marked `"use client"`: + +| File | Issue | +| ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| [client-side-page.tsx]() | Renders entire page layout client-side. Only uses `useContext`. | +| [home-page-layout.tsx]() | Same pattern — wraps the entire homepage in `useContext` calls. | +| [blog/page.tsx]() | Pages list is fully client-rendered. | +| [products/page.tsx]() | Same as blog. | +| [checkout/page.tsx]() | Same pattern. | +| [layout-with-context.tsx]() | The entire context provider tree is client-side. | + +**The real issue**: Because `layout-with-context.tsx` is a `"use client"` boundary, **every child component** inherits the client boundary. This means `BaseLayout` and all its page blocks — which could potentially render as server components — are bundled as client JS. + +### The Fix + +Refactor the context pattern to pass data via props from Server Components and push `"use client"` to leaf components that actually need interactivity: + +1. Move `siteinfo`, `theme`, `address`, `config` from React Context to Server Component props (they're already server-fetched). +2. Only use contexts for truly interactive state like `profile` and `setProfile`. +3. Make `BaseLayout` a Server Component that renders block widgets server-side and only wraps interactive blocks in client boundaries. + +This is a larger refactor but dramatically reduces the initial JS payload for public pages. + +--- + +## 🟢 6. Optimize Font Loading (Low-Medium ROI) + +**Impact: ~200–500KB CSS savings, faster FCP** +**Effort: Small** + +### The Problem + +The [root layout](file:///Users/rajat/dev/projects/courselit/apps/web/app/layout.tsx#L63) loads **21 font families** on every single page via CSS variable declarations: + +```tsx +className={`${fonts.openSans.variable} ${fonts.montserrat.variable} +${fonts.lato.variable} ${fonts.poppins.variable} ... (21 total)`} +``` + +Each tenant's theme only uses 1-2 of these fonts, but all 21 are preloaded, generating ~21 `@font-face` declarations and preload hints. + +### The Fix + +Dynamically select fonts based on the tenant's theme configuration: + +```typescript +// In RootLayout +const themeTypefaces = siteSetup?.theme?.theme?.typography; +const requiredFonts = getRequiredFontVariables(themeTypefaces); + +// Only include font variables for fonts actually used by this tenant + +``` + +--- + +## 🟢 7. HTTP Response Caching with `Cache-Control` Headers (Low-Medium ROI) + +**Impact: Faster repeat visits, reduced server load** +**Effort: Small** + +### The Problem + +No `Cache-Control` headers are set on any public pages. The `verify-domain` route is explicitly `force-dynamic`. + +### The Fix + +For public pages that don't depend on user session, add appropriate caching headers: + +```typescript +// In public page layouts, for anonymous users +export const revalidate = 60; // ISR: revalidate every 60 seconds +``` + +Or use `next.config.js` headers for static assets and API responses: + +```javascript +async headers() { + return [ + { + source: '/p/:path*', + headers: [ + { key: 'Cache-Control', value: 's-maxage=60, stale-while-revalidate=300' }, + ], + }, + ]; +} +``` + +For a multi-tenant setup, put a CDN (Cloudflare, CloudFront) in front with **Vary: Host** so each tenant gets its own cache partition. + +--- + +## Priority Summary + +| # | Optimization | Impact | Effort | ROI | +| :-: | -------------------------------------- | ----------- | --------- | :--------: | +| 1 | `React.cache()` on data fetchers | 🔴 Critical | ⬜ Small | ⭐⭐⭐⭐⭐ | +| 2 | Cache `verify-domain` | 🔴 High | ⬜ Small | ⭐⭐⭐⭐⭐ | +| 3 | Direct DB calls from Server Components | 🟡 High | 🟨 Medium | ⭐⭐⭐⭐ | +| 4 | Redis caching layer | 🟡 High | 🟨 Medium | ⭐⭐⭐⭐ | +| 5 | Convert client → server components | 🟡 Medium | 🟥 High | ⭐⭐⭐ | +| 6 | Optimize font loading | 🟢 Medium | ⬜ Small | ⭐⭐⭐ | +| 7 | HTTP caching / CDN | 🟢 Medium | ⬜ Small | ⭐⭐⭐ | + +> [!TIP] > **Recommended first step**: Apply optimization #1 (`React.cache()`) — it's a 15-minute change that will immediately cut your per-page HTTP requests from ~11 down to ~3, giving you the biggest bang for minimal effort. diff --git a/apps/web/app/verify-domain/route.ts b/apps/web/app/verify-domain/route.ts index b683b1df2..7f4f72c9b 100644 --- a/apps/web/app/verify-domain/route.ts +++ b/apps/web/app/verify-domain/route.ts @@ -8,33 +8,10 @@ import connectToDatabase from "../../services/db"; import { warn } from "@/services/logger"; import SubscriberModel, { Subscriber } from "@models/Subscriber"; import { Constants } from "@courselit/common-models"; +import { getCachedDomain, invalidateDomainCache } from "@/lib/domain-cache"; const { domainNameForSingleTenancy, schoolNameForSingleTenancy } = constants; -const getDomainBasedOnSubdomain = async ( - subdomain: string, -): Promise => { - return await DomainModel.findOne({ name: subdomain, deleted: false }); -}; - -const getDomainBasedOnCustomDomain = async ( - customDomain: string, -): Promise => { - return await DomainModel.findOne({ customDomain, deleted: false }); -}; - -const getDomain = async (hostName: string): Promise => { - const isProduction = process.env.NODE_ENV === "production"; - const isSubdomain = hostName.endsWith(`.${process.env.DOMAIN}`); - - if (isProduction && (hostName === process.env.DOMAIN || !isSubdomain)) { - return getDomainBasedOnCustomDomain(hostName); - } - - const [subdomain] = hostName?.split("."); - return getDomainBasedOnSubdomain(subdomain); -}; - export const dynamic = "force-dynamic"; export async function GET(req: Request) { @@ -57,7 +34,7 @@ export async function GET(req: Request) { ); } - domain = await getDomain(host); + domain = await getCachedDomain(host); if (!domain) { return Response.json( @@ -113,11 +90,10 @@ export async function GET(req: Request) { { $set: { checkSubscriptionStatusAfter: dateAfter24Hours } }, { upsert: false }, ); + invalidateDomainCache(host); } } else { - domain = await DomainModel.findOne({ - name: domainNameForSingleTenancy, - }); + domain = await getCachedDomain(domainNameForSingleTenancy); if (!domain) { if (!process.env.SUPER_ADMIN_EMAIL) { @@ -175,6 +151,11 @@ export async function GET(req: Request) { { $set: { firstRun: false } }, { upsert: false }, ); + invalidateDomainCache( + constants.multitenant + ? headerList.get("host") || "" + : domainNameForSingleTenancy, + ); } catch (err) { warn(`Error in creating user: ${err.message}`, { domain: domain?.name, diff --git a/apps/web/lib/domain-cache.ts b/apps/web/lib/domain-cache.ts new file mode 100644 index 000000000..589eb1bd6 --- /dev/null +++ b/apps/web/lib/domain-cache.ts @@ -0,0 +1,48 @@ +import DomainModel, { Domain } from "../models/Domain"; + +const domainCache = new Map< + string, + { data: Domain | null; expiresAt: number } +>(); +const TTL = 60_000; // 60 seconds + +const getDomainBasedOnSubdomain = async ( + subdomain: string, +): Promise => { + return await DomainModel.findOne({ name: subdomain, deleted: false }); +}; + +const getDomainBasedOnCustomDomain = async ( + customDomain: string, +): Promise => { + return await DomainModel.findOne({ customDomain, deleted: false }); +}; + +const getDomain = async (hostName: string): Promise => { + const isProduction = process.env.NODE_ENV === "production"; + const isSubdomain = hostName.endsWith(`.${process.env.DOMAIN}`); + + if (isProduction && (hostName === process.env.DOMAIN || !isSubdomain)) { + return getDomainBasedOnCustomDomain(hostName); + } + + const [subdomain] = hostName?.split("."); + return getDomainBasedOnSubdomain(subdomain); +}; + +export async function getCachedDomain( + hostName: string, +): Promise { + const cached = domainCache.get(hostName); + if (cached && cached.expiresAt > Date.now()) { + return cached.data; + } + + const domain = await getDomain(hostName); + domainCache.set(hostName, { data: domain, expiresAt: Date.now() + TTL }); + return domain; +} + +export function invalidateDomainCache(hostName: string): void { + domainCache.delete(hostName); +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818fb..0c7fad710 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited diff --git a/apps/web/ui-lib/utils.ts b/apps/web/ui-lib/utils.ts index 4306621cf..eb3ca06aa 100644 --- a/apps/web/ui-lib/utils.ts +++ b/apps/web/ui-lib/utils.ts @@ -1,3 +1,4 @@ +import { cache } from "react"; import type { CommunityMemberStatus, CommunityReportStatus, @@ -73,12 +74,10 @@ type FrontEndPage = Pick< | "socialImage" | "robotsAllowed" >; -export const getPage = async ( - backend: string, - id?: string, -): Promise => { - const query = id - ? ` +export const getPage = cache( + async (backend: string, id?: string): Promise => { + const query = id + ? ` query { page: getPage(id: "${id}") { type, @@ -96,7 +95,7 @@ export const getPage = async ( } } ` - : ` + : ` query { page: getPage { type, @@ -112,24 +111,24 @@ export const getPage = async ( } } `; - try { - const fetch = new FetchBuilder() - .setUrl(`${backend}/api/graph`) - .setPayload(query) - .setIsGraphQLEndpoint(true) - .build(); - const response = await fetch.exec(); - return response.page; - } catch (e: any) { - console.log("getPage", e.message); // eslint-disable-line no-console - } - return null; -}; + try { + const fetch = new FetchBuilder() + .setUrl(`${backend}/api/graph`) + .setPayload(query) + .setIsGraphQLEndpoint(true) + .build(); + const response = await fetch.exec(); + return response.page; + } catch (e: any) { + console.log("getPage", e.message); // eslint-disable-line no-console + } + return null; + }, +); -export const getSiteInfo = async ( - backend: string, -): Promise => { - const query = ` +export const getSiteInfo = cache( + async (backend: string): Promise => { + const query = ` query { site: getSiteInfo { settings { @@ -156,32 +155,34 @@ export const getSiteInfo = async ( } } `; - try { - const fetch = new FetchBuilder() - .setUrl(`${backend}/api/graph`) - .setPayload(query) - .setIsGraphQLEndpoint(true) - .build(); - const response = await fetch.exec(); - return response.site.settings; - } catch (e: any) { - console.log("getSiteInfo", e.message); // eslint-disable-line no-console - } -}; + try { + const fetch = new FetchBuilder() + .setUrl(`${backend}/api/graph`) + .setPayload(query) + .setIsGraphQLEndpoint(true) + .build(); + const response = await fetch.exec(); + return response.site.settings; + } catch (e: any) { + console.log("getSiteInfo", e.message); // eslint-disable-line no-console + } + }, +); -export const getFullSiteSetup = async ( - backend: string, - id?: string, -): Promise< - | { - settings: SiteInfo; - theme: Theme; - page: FrontEndPage; - features: Features[]; - } - | undefined -> => { - const query = ` +export const getFullSiteSetup = cache( + async ( + backend: string, + id?: string, + ): Promise< + | { + settings: SiteInfo; + theme: Theme; + page: FrontEndPage; + features: Features[]; + } + | undefined + > => { + const query = ` query ($id: String) { theme: getTheme { themeId @@ -210,35 +211,36 @@ export const getFullSiteSetup = async ( features: getFeatures } `; - const fetch = new FetchBuilder() - .setUrl(`${backend}/api/graph`) - .setPayload({ query, variables: { id } }) - .setIsGraphQLEndpoint(true) - .build(); + const fetch = new FetchBuilder() + .setUrl(`${backend}/api/graph`) + .setPayload({ query, variables: { id } }) + .setIsGraphQLEndpoint(true) + .build(); - const settings = await getSiteInfo(backend); - if (!settings) { - return undefined; - } + const settings = await getSiteInfo(backend); + if (!settings) { + return undefined; + } - try { - const response = await fetch.exec(); - const transformedTheme: Theme = { - id: response.theme.themeId, - name: response.theme.name, - theme: response.theme.theme, - }; - return { - settings, - theme: transformedTheme, - page: response.page, - features: response.features, - }; - } catch (e: any) { - console.log("getSiteInfo", e.message); // eslint-disable-line no-console - return undefined; - } -}; + try { + const response = await fetch.exec(); + const transformedTheme: Theme = { + id: response.theme.themeId, + name: response.theme.name, + theme: response.theme.theme, + }; + return { + settings, + theme: transformedTheme, + page: response.page, + features: response.features, + }; + } catch (e: any) { + console.log("getSiteInfo", e.message); // eslint-disable-line no-console + return undefined; + } + }, +); export const isEnrolled = (courseId: string, profile: Profile) => profile.purchases.some((purchase: any) => purchase.courseId === courseId); From 24c13cc6521de2e740b786c8b8ee8a75fe570397 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 14 Feb 2026 12:37:33 +0530 Subject: [PATCH 2/8] perf: optimize GraphQL route and add domain caching - Wrap data-fetching functions (getPage, getSiteInfo, getFullSiteSetup) with React.cache() to deduplicate SSR calls - Add in-memory domain cache with 60s TTL (domain-cache.ts) - Use getCachedDomain in verify-domain route and GraphQL route handler - Parallelize domain lookup, session check, and body parsing with Promise.all in GraphQL route - Store plain objects in domain cache, hydrate fresh Mongoose docs per request to prevent cross-request mutation --- apps/web/app/api/graph/route.ts | 28 +++++++++++----------------- apps/web/graphql/pages/logic.ts | 15 ++++++++++----- apps/web/graphql/themes/logic.ts | 14 +++++++++----- apps/web/lib/domain-cache.ts | 11 ++++++++--- 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/apps/web/app/api/graph/route.ts b/apps/web/app/api/graph/route.ts index c25e41539..48c298658 100644 --- a/apps/web/app/api/graph/route.ts +++ b/apps/web/app/api/graph/route.ts @@ -3,9 +3,9 @@ import schema from "@/graphql"; import { graphql } from "graphql"; import { getAddress } from "@/lib/utils"; import User from "@models/User"; -import DomainModel, { Domain } from "@models/Domain"; import { auth } from "@/auth"; import { als } from "@/async-local-storage"; +import { getCachedDomain } from "@/lib/domain-cache"; async function updateLastActive(user: any) { const dateNow = new Date(); @@ -20,26 +20,25 @@ async function updateLastActive(user: any) { } export async function POST(req: NextRequest) { - const domain = await DomainModel.findOne({ - name: req.headers.get("domain"), - }); + const [domain, session, body] = await Promise.all([ + getCachedDomain(req.headers.get("domain")!), + auth.api.getSession({ headers: req.headers }), + req.json(), + ]); + if (!domain) { return Response.json({ message: "Domain not found" }, { status: 404 }); } + if (!body.hasOwnProperty("query")) { + return Response.json({ error: "Query is missing" }, { status: 400 }); + } + const map = new Map(); map.set("domain", req.headers.get("domain")); map.set("domainId", req.headers.get("domainId")); als.enterWith(map); - const session = await auth.api.getSession({ - headers: req.headers, - }); - const body = await req.json(); - if (!body.hasOwnProperty("query")) { - return Response.json({ error: "Query is missing" }, { status: 400 }); - } - let user; if (session) { user = await User.findOne({ @@ -53,11 +52,6 @@ export async function POST(req: NextRequest) { } } - // const body = await req.json(); - // if (!body.hasOwnProperty("query")) { - // return Response.json({ error: "Query is missing" }, { status: 400 }); - // } - let query, variables; if (typeof body.query === "string") { query = body.query; diff --git a/apps/web/graphql/pages/logic.ts b/apps/web/graphql/pages/logic.ts index 4c159311f..f7f39468c 100644 --- a/apps/web/graphql/pages/logic.ts +++ b/apps/web/graphql/pages/logic.ts @@ -1,4 +1,5 @@ import { responses } from "../../config/strings"; +import DomainModel from "@models/Domain"; import { checkIfAuthenticated } from "../../lib/graphql"; import GQLContext from "../../models/GQLContext"; import PageModel, { Page } from "../../models/Page"; @@ -280,15 +281,19 @@ export const publish = async ( } page.socialImage = page.draftSocialImage; - ctx.subdomain.typefaces = ctx.subdomain.draftTypefaces; - ctx.subdomain.sharedWidgets = ctx.subdomain.draftSharedWidgets; - // ctx.subdomain.draftSharedWidgets = {}; - if (ctx.subdomain.themeId) { await publishTheme(ctx.subdomain.themeId, ctx); } - await (ctx.subdomain as any).save(); + await DomainModel.findOneAndUpdate( + { _id: ctx.subdomain._id }, + { + $set: { + typefaces: ctx.subdomain.draftTypefaces, + sharedWidgets: ctx.subdomain.draftSharedWidgets, + }, + }, + ); for (const mediaId of mediaToDelete) { await deleteMedia(mediaId); } diff --git a/apps/web/graphql/themes/logic.ts b/apps/web/graphql/themes/logic.ts index ac7bd3f8d..b75f0128a 100644 --- a/apps/web/graphql/themes/logic.ts +++ b/apps/web/graphql/themes/logic.ts @@ -1,4 +1,5 @@ import { checkIfAuthenticated } from "../../lib/graphql"; +import DomainModel from "@models/Domain"; import { responses } from "../../config/strings"; import constants from "../../config/constants"; import GQLContext from "../../models/GQLContext"; @@ -140,8 +141,10 @@ export const updateDraftTheme = async ( } await theme.save(); - ctx.subdomain.lastEditedThemeId = theme.themeId; - await (ctx.subdomain as any).save(); + await DomainModel.findOneAndUpdate( + { _id: ctx.subdomain._id }, + { $set: { lastEditedThemeId: theme.themeId } }, + ); return formatTheme(theme); }; @@ -216,9 +219,10 @@ export const switchTheme = async (themeId: string, ctx: GQLContext) => { theme = await publishTheme(themeId, ctx); } - ctx.subdomain.themeId = themeId; - ctx.subdomain.lastEditedThemeId = themeId; - await (ctx.subdomain as any).save(); + await DomainModel.findOneAndUpdate( + { _id: ctx.subdomain._id }, + { $set: { themeId, lastEditedThemeId: themeId } }, + ); return formatTheme(theme); }; diff --git a/apps/web/lib/domain-cache.ts b/apps/web/lib/domain-cache.ts index 589eb1bd6..ffa3409ac 100644 --- a/apps/web/lib/domain-cache.ts +++ b/apps/web/lib/domain-cache.ts @@ -2,7 +2,7 @@ import DomainModel, { Domain } from "../models/Domain"; const domainCache = new Map< string, - { data: Domain | null; expiresAt: number } + { data: Record; expiresAt: number } >(); const TTL = 60_000; // 60 seconds @@ -35,11 +35,16 @@ export async function getCachedDomain( ): Promise { const cached = domainCache.get(hostName); if (cached && cached.expiresAt > Date.now()) { - return cached.data; + return DomainModel.hydrate(cached.data) as unknown as Domain; } const domain = await getDomain(hostName); - domainCache.set(hostName, { data: domain, expiresAt: Date.now() + TTL }); + if (domain) { + domainCache.set(hostName, { + data: (domain as any).toObject(), + expiresAt: Date.now() + TTL, + }); + } return domain; } From dd66a4f9e199bcfe7c76816d924f49e3994075a7 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 14 Feb 2026 12:37:33 +0530 Subject: [PATCH 3/8] perf: optimize GraphQL route and add domain caching - Wrap data-fetching functions (getPage, getSiteInfo, getFullSiteSetup) with React.cache() to deduplicate SSR calls - Add in-memory domain cache with 60s TTL (domain-cache.ts) - Use getCachedDomain in verify-domain route and GraphQL route handler - Parallelize domain lookup, session check, and body parsing with Promise.all in GraphQL route - Store plain objects in domain cache, hydrate fresh Mongoose docs per request to prevent cross-request mutation --- apps/web/PERFORMANCE_REPORT.md | 82 +++++----------------------------- 1 file changed, 12 insertions(+), 70 deletions(-) diff --git a/apps/web/PERFORMANCE_REPORT.md b/apps/web/PERFORMANCE_REPORT.md index b951b6145..afcf90252 100644 --- a/apps/web/PERFORMANCE_REPORT.md +++ b/apps/web/PERFORMANCE_REPORT.md @@ -99,69 +99,13 @@ For multi-server environments, use **Redis** to cache the domain lookup result k --- -## 🟡 3. Direct Database Queries Instead of HTTP Self-Fetch (High ROI) +## ~~🟡 3. Direct Database Queries Instead of HTTP Self-Fetch~~ — Not Viable -**Impact: ~50–100ms saved per internal API call** -**Effort: Medium** - -### The Problem - -Server Components fetch data by making **HTTP requests to themselves** via the GraphQL API: - -```typescript -// ui-lib/utils.ts - Server component making an HTTP request to itself -const fetch = new FetchBuilder() - .setUrl(`${backend}/api/graph`) // self-referencing HTTP call! - .setPayload({ query }) - .build(); -``` - -Each call to `/api/graph` goes through: - -1. Network round-trip (even if localhost, ~5-15ms) -2. Next.js request handling + middleware re-execution -3. [GraphQL route handler](file:///Users/rajat/dev/projects/courselit/apps/web/app/api/graph/route.ts#L22-L84) does another `DomainModel.findOne()` + `auth.api.getSession()` + `User.findOne()` -4. Then the actual resolver logic runs - -### The Fix - -**For public pages** (no auth needed), call the database models directly from Server Components: - -```typescript -// lib/public-queries.ts -import PageModel from "@models/Page"; -import DomainModel from "@models/Domain"; -import { cache } from "react"; - -export const getPublicPage = cache(async (domainId: string, pageId: string) => { - return PageModel.findOne( - { pageId, domain: domainId }, - { - layout: 1, - title: 1, - description: 1, - socialImage: 1, - robotsAllowed: 1, - }, - ).lean(); -}); - -export const getPublicSiteSetup = cache(async (domainId: string) => { - const [domain, theme] = await Promise.all([ - DomainModel.findById(domainId, { - /* projections */ - }).lean(), - ThemeModel.findOne({ domain: domainId, active: true }).lean(), - ]); - return { settings: domain?.settings, theme, features: domain?.features }; -}); -``` - -This eliminates the HTTP round-trip overhead and the redundant auth/domain resolution in the GraphQL handler. +> [!CAUTION] > **Decision: Keep data fetching through the GraphQL API.** The GraphQL resolvers contain critical business logic that runs on first access — such as `initSharedWidgets`, permission checks, and admin-vs-public field filtering. Duplicating this logic in direct DB queries would be fragile, error-prone, and hard to maintain. The HTTP self-fetch overhead is acceptable given the `React.cache()` deduplication (item #1) and domain caching (item #2) already in place. --- -## 🟡 4. Redis Caching Layer for Tenant Data (High ROI for Multi-Tenant) +## 🟡 4. Redis Caching Layer for Tenant Data — Phase 2 **Impact: ~80–95% reduction in MongoDB load for public pages** **Effort: Medium** @@ -318,14 +262,12 @@ For a multi-tenant setup, put a CDN (Cloudflare, CloudFront) in front with **Var ## Priority Summary -| # | Optimization | Impact | Effort | ROI | -| :-: | -------------------------------------- | ----------- | --------- | :--------: | -| 1 | `React.cache()` on data fetchers | 🔴 Critical | ⬜ Small | ⭐⭐⭐⭐⭐ | -| 2 | Cache `verify-domain` | 🔴 High | ⬜ Small | ⭐⭐⭐⭐⭐ | -| 3 | Direct DB calls from Server Components | 🟡 High | 🟨 Medium | ⭐⭐⭐⭐ | -| 4 | Redis caching layer | 🟡 High | 🟨 Medium | ⭐⭐⭐⭐ | -| 5 | Convert client → server components | 🟡 Medium | 🟥 High | ⭐⭐⭐ | -| 6 | Optimize font loading | 🟢 Medium | ⬜ Small | ⭐⭐⭐ | -| 7 | HTTP caching / CDN | 🟢 Medium | ⬜ Small | ⭐⭐⭐ | - -> [!TIP] > **Recommended first step**: Apply optimization #1 (`React.cache()`) — it's a 15-minute change that will immediately cut your per-page HTTP requests from ~11 down to ~3, giving you the biggest bang for minimal effort. +| # | Optimization | Impact | Effort | Status | +| :-: | -------------------------------------- | ----------- | --------- | :-----------: | +| 1 | `React.cache()` on data fetchers | 🔴 Critical | ⬜ Small | ✅ Done | +| 2 | Cache `verify-domain` | 🔴 High | ⬜ Small | ✅ Done | +| 3 | Direct DB calls from Server Components | 🟡 High | 🟨 Medium | ❌ Not viable | +| 4 | Redis caching layer | 🟡 High | 🟨 Medium | ⬜ Phase 2 | +| 5 | Convert client → server components | 🟡 Medium | 🟥 High | ⬜ Phase 2 | +| 6 | Optimize font loading | 🟢 Medium | ⬜ Small | ⬜ Phase 2 | +| 7 | HTTP caching / CDN | 🟢 Medium | ⬜ Small | ⬜ Phase 2 | From 46acb689a748fb029c6e4a6504f71b5a418a8bed Mon Sep 17 00:00:00 2001 From: Rajat Date: Thu, 26 Feb 2026 23:16:20 +0530 Subject: [PATCH 4/8] Performance report tasklist --- {apps/web => docs/wip}/PERFORMANCE_REPORT.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) rename {apps/web => docs/wip}/PERFORMANCE_REPORT.md (94%) diff --git a/apps/web/PERFORMANCE_REPORT.md b/docs/wip/PERFORMANCE_REPORT.md similarity index 94% rename from apps/web/PERFORMANCE_REPORT.md rename to docs/wip/PERFORMANCE_REPORT.md index afcf90252..ccfff2bcb 100644 --- a/apps/web/PERFORMANCE_REPORT.md +++ b/docs/wip/PERFORMANCE_REPORT.md @@ -271,3 +271,15 @@ For a multi-tenant setup, put a CDN (Cloudflare, CloudFront) in front with **Var | 5 | Convert client → server components | 🟡 Medium | 🟥 High | ⬜ Phase 2 | | 6 | Optimize font loading | 🟢 Medium | ⬜ Small | ⬜ Phase 2 | | 7 | HTTP caching / CDN | 🟢 Medium | ⬜ Small | ⬜ Phase 2 | + +## Task Checklist + +- [x] Add `React.cache()` wrappers for `getPage`, `getSiteInfo`, and `getFullSiteSetup`. +- [x] Add in-memory domain cache (`TTL = 60s`) and use it in `verify-domain`. +- [x] Use cached domain lookup in `/api/graph` and parallelize domain/session/body fetch with `Promise.all`. +- [x] Avoid cross-request mutation by caching plain domain objects and hydrating per request. +- [x] Record decision for item #3: direct DB replacement for GraphQL self-fetch is not viable. +- [ ] Add Redis tenant cache layer with resolver-level invalidation. +- [ ] Refactor public rendering path from broad client boundaries to server components. +- [ ] Load fonts dynamically per tenant theme instead of preloading all families. +- [ ] Add HTTP caching strategy (`revalidate`/`Cache-Control`) and CDN partitioning with `Vary: Host`. From b30914161aa892d7b92abef2925025e0f07e508a Mon Sep 17 00:00:00 2001 From: Rajat Date: Sat, 28 Feb 2026 18:18:29 +0530 Subject: [PATCH 5/8] multitenant domain cache fixed --- apps/web/app/api/graph/route.ts | 19 +++- .../__tests__/resolve-domain.test.ts | 92 ++++++++++++++++ apps/web/app/verify-domain/resolve-domain.ts | 21 ++++ apps/web/app/verify-domain/route.ts | 27 +++-- apps/web/jest.server.config.ts | 2 +- apps/web/lib/__tests__/domain-cache.test.ts | 55 ++++++++++ apps/web/lib/domain-cache.ts | 103 ++++++++++++++---- apps/web/models/Domain.ts | 83 ++------------ apps/web/models/SiteInfo.ts | 32 ------ apps/web/next-env.d.ts | 2 +- apps/web/package.json | 15 +++ docs/wip/PERFORMANCE_REPORT.md | 52 ++++----- packages/orm-models/src/models/domain.ts | 20 ++++ packages/orm-models/src/models/site-info.ts | 1 + packages/orm-models/src/models/typeface.ts | 27 +++++ 15 files changed, 381 insertions(+), 170 deletions(-) create mode 100644 apps/web/app/verify-domain/__tests__/resolve-domain.test.ts create mode 100644 apps/web/app/verify-domain/resolve-domain.ts create mode 100644 apps/web/lib/__tests__/domain-cache.test.ts delete mode 100644 apps/web/models/SiteInfo.ts create mode 100644 packages/orm-models/src/models/typeface.ts diff --git a/apps/web/app/api/graph/route.ts b/apps/web/app/api/graph/route.ts index 48c298658..8835a3a2a 100644 --- a/apps/web/app/api/graph/route.ts +++ b/apps/web/app/api/graph/route.ts @@ -20,14 +20,25 @@ async function updateLastActive(user: any) { } export async function POST(req: NextRequest) { + const domainName = req.headers.get("domain"); + if (!domainName) { + return Response.json( + { errors: [{ message: "Domain header is missing" }] }, + { status: 400 }, + ); + } + const [domain, session, body] = await Promise.all([ - getCachedDomain(req.headers.get("domain")!), + getCachedDomain(domainName), auth.api.getSession({ headers: req.headers }), req.json(), ]); if (!domain) { - return Response.json({ message: "Domain not found" }, { status: 404 }); + return Response.json( + { errors: [{ message: "Domain not found" }] }, + { status: 404 }, + ); } if (!body.hasOwnProperty("query")) { @@ -35,8 +46,8 @@ export async function POST(req: NextRequest) { } const map = new Map(); - map.set("domain", req.headers.get("domain")); - map.set("domainId", req.headers.get("domainId")); + map.set("domain", domainName); + map.set("domainId", req.headers.get("domainId") || domain._id.toString()); als.enterWith(map); let user; diff --git a/apps/web/app/verify-domain/__tests__/resolve-domain.test.ts b/apps/web/app/verify-domain/__tests__/resolve-domain.test.ts new file mode 100644 index 000000000..c2e91522e --- /dev/null +++ b/apps/web/app/verify-domain/__tests__/resolve-domain.test.ts @@ -0,0 +1,92 @@ +/** + * @jest-environment node + */ + +import { resolveDomainFromHost } from "@/app/verify-domain/resolve-domain"; +import { getCachedDomain, getDomainFromHost } from "@/lib/domain-cache"; + +jest.mock("@/lib/domain-cache", () => ({ + getCachedDomain: jest.fn(), + getDomainFromHost: jest.fn(), +})); + +describe("verify-domain resolution", () => { + const mockGetCachedDomain = getCachedDomain as jest.Mock; + const mockGetDomainFromHost = getDomainFromHost as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("MULTITENANT=true resolves school by subdomain host", async () => { + mockGetDomainFromHost.mockResolvedValue({ name: "domain1" }); + + const domain = await resolveDomainFromHost({ + multitenant: true, + host: "domain1.clqa.site", + domainNameForSingleTenancy: "main", + }); + + expect(mockGetDomainFromHost).toHaveBeenCalledWith("domain1.clqa.site"); + expect(mockGetCachedDomain).not.toHaveBeenCalled(); + expect(domain?.name).toBe("domain1"); + }); + + it("MULTITENANT=true resolves school by custom domain host", async () => { + mockGetDomainFromHost.mockResolvedValue({ name: "domain1" }); + + const domain = await resolveDomainFromHost({ + multitenant: true, + host: "school.example.com", + domainNameForSingleTenancy: "main", + }); + + expect(mockGetDomainFromHost).toHaveBeenCalledWith( + "school.example.com", + ); + expect(mockGetCachedDomain).not.toHaveBeenCalled(); + expect(domain?.name).toBe("domain1"); + }); + + it("MULTITENANT=false resolves school by DOMAIN_NAME_FOR_SINGLE_TENANCY for custom domain access", async () => { + mockGetCachedDomain.mockResolvedValue({ name: "main" }); + + const domain = await resolveDomainFromHost({ + multitenant: false, + host: "school.example.com", + domainNameForSingleTenancy: "main", + }); + + expect(mockGetCachedDomain).toHaveBeenCalledWith("main"); + expect(mockGetDomainFromHost).not.toHaveBeenCalled(); + expect(domain?.name).toBe("main"); + }); + + it("MULTITENANT=false resolves school by DOMAIN_NAME_FOR_SINGLE_TENANCY for IP access", async () => { + mockGetCachedDomain.mockResolvedValue({ name: "main" }); + + const domain = await resolveDomainFromHost({ + multitenant: false, + host: "10.0.0.1", + domainNameForSingleTenancy: "main", + }); + + expect(mockGetCachedDomain).toHaveBeenCalledWith("main"); + expect(mockGetDomainFromHost).not.toHaveBeenCalled(); + expect(domain?.name).toBe("main"); + }); + + it("MULTITENANT=false resolves school by DOMAIN_NAME_FOR_SINGLE_TENANCY for IP:PORT access", async () => { + mockGetCachedDomain.mockResolvedValue({ name: "main" }); + + const domain = await resolveDomainFromHost({ + multitenant: false, + host: "192.168.0.1:3000", + domainNameForSingleTenancy: "main", + }); + + expect(mockGetCachedDomain).toHaveBeenCalledWith("main"); + expect(mockGetDomainFromHost).not.toHaveBeenCalled(); + expect(domain?.name).toBe("main"); + }); +}); diff --git a/apps/web/app/verify-domain/resolve-domain.ts b/apps/web/app/verify-domain/resolve-domain.ts new file mode 100644 index 000000000..e7593c839 --- /dev/null +++ b/apps/web/app/verify-domain/resolve-domain.ts @@ -0,0 +1,21 @@ +import { Domain } from "@/models/Domain"; +import { getCachedDomain, getDomainFromHost } from "@/lib/domain-cache"; + +export async function resolveDomainFromHost({ + multitenant, + host, + domainNameForSingleTenancy, +}: { + multitenant: boolean; + host: string | null; + domainNameForSingleTenancy: string; +}): Promise { + if (multitenant) { + if (!host) { + return null; + } + return getDomainFromHost(host); + } + + return getCachedDomain(domainNameForSingleTenancy); +} diff --git a/apps/web/app/verify-domain/route.ts b/apps/web/app/verify-domain/route.ts index 7f4f72c9b..641abdca6 100644 --- a/apps/web/app/verify-domain/route.ts +++ b/apps/web/app/verify-domain/route.ts @@ -8,7 +8,8 @@ import connectToDatabase from "../../services/db"; import { warn } from "@/services/logger"; import SubscriberModel, { Subscriber } from "@models/Subscriber"; import { Constants } from "@courselit/common-models"; -import { getCachedDomain, invalidateDomainCache } from "@/lib/domain-cache"; +import { cacheDomainByName, invalidateDomainCache } from "@/lib/domain-cache"; +import { resolveDomainFromHost } from "./resolve-domain"; const { domainNameForSingleTenancy, schoolNameForSingleTenancy } = constants; @@ -34,7 +35,11 @@ export async function GET(req: Request) { ); } - domain = await getCachedDomain(host); + domain = await resolveDomainFromHost({ + multitenant: constants.multitenant, + host, + domainNameForSingleTenancy, + }); if (!domain) { return Response.json( @@ -90,10 +95,16 @@ export async function GET(req: Request) { { $set: { checkSubscriptionStatusAfter: dateAfter24Hours } }, { upsert: false }, ); - invalidateDomainCache(host); + invalidateDomainCache(domain.name); } + + cacheDomainByName(domain); } else { - domain = await getCachedDomain(domainNameForSingleTenancy); + domain = await resolveDomainFromHost({ + multitenant: constants.multitenant, + host: headerList.get("host"), + domainNameForSingleTenancy, + }); if (!domain) { if (!process.env.SUPER_ADMIN_EMAIL) { @@ -134,6 +145,8 @@ export async function GET(req: Request) { }, ); } + + cacheDomainByName(domain!); } if (domain!.firstRun) { @@ -151,11 +164,7 @@ export async function GET(req: Request) { { $set: { firstRun: false } }, { upsert: false }, ); - invalidateDomainCache( - constants.multitenant - ? headerList.get("host") || "" - : domainNameForSingleTenancy, - ); + invalidateDomainCache(domain!.name); } catch (err) { warn(`Error in creating user: ${err.message}`, { domain: domain?.name, diff --git a/apps/web/jest.server.config.ts b/apps/web/jest.server.config.ts index 9d97b7179..6422415c7 100644 --- a/apps/web/jest.server.config.ts +++ b/apps/web/jest.server.config.ts @@ -44,7 +44,7 @@ const config: Config = { }, testMatch: [ "**/graphql/**/__tests__/**/*.test.ts", - "**/api/**/__tests__/**/*.test.ts", + "**/app/**/__tests__/**/*.test.ts", ], testPathIgnorePatterns: [ "/node_modules/", diff --git a/apps/web/lib/__tests__/domain-cache.test.ts b/apps/web/lib/__tests__/domain-cache.test.ts new file mode 100644 index 000000000..6a65cc2f2 --- /dev/null +++ b/apps/web/lib/__tests__/domain-cache.test.ts @@ -0,0 +1,55 @@ +/** + * @jest-environment node + */ + +jest.mock("../../models/Domain", () => ({ + __esModule: true, + default: { + findOne: jest.fn(), + hydrate: jest.fn((data: unknown) => data), + }, +})); + +import DomainModel from "../../models/Domain"; +import { getDomainFromHost } from "../domain-cache"; + +describe("domain-cache host resolution", () => { + const originalDomain = process.env.DOMAIN; + const mockFindOne = (DomainModel as any).findOne as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + process.env.DOMAIN = originalDomain; + }); + + it("resolves subdomain when DOMAIN includes port", async () => { + process.env.DOMAIN = "localhost:3000"; + const school = { name: "innerevolution" }; + mockFindOne.mockResolvedValue(school); + + const domain = await getDomainFromHost("innerevolution.localhost:3000"); + + expect(mockFindOne).toHaveBeenCalledWith({ + name: "innerevolution", + deleted: false, + }); + expect(domain).toBe(school); + }); + + it("uses custom-domain lookup when host is outside configured root domain", async () => { + process.env.DOMAIN = "clqa.site"; + const school = { name: "domain1" }; + mockFindOne.mockResolvedValue(school); + + const domain = await getDomainFromHost("school.example.com"); + + expect(mockFindOne).toHaveBeenCalledWith({ + customDomain: "school.example.com", + deleted: false, + }); + expect(domain).toBe(school); + }); +}); diff --git a/apps/web/lib/domain-cache.ts b/apps/web/lib/domain-cache.ts index ffa3409ac..65fede32d 100644 --- a/apps/web/lib/domain-cache.ts +++ b/apps/web/lib/domain-cache.ts @@ -1,11 +1,16 @@ import DomainModel, { Domain } from "../models/Domain"; -const domainCache = new Map< - string, - { data: Record; expiresAt: number } ->(); +type CacheEntry = { data: Record; expiresAt: number }; + +const domainCacheByName = new Map(); +const domainCacheByHost = new Map(); const TTL = 60_000; // 60 seconds +const normalizeHost = (hostName: string): string => + (hostName || "").split(",")[0].trim().split(":")[0].toLowerCase(); +const normalizeDomainName = (domainName: string): string => + (domainName || "").trim().toLowerCase(); + const getDomainBasedOnSubdomain = async ( subdomain: string, ): Promise => { @@ -18,36 +23,92 @@ const getDomainBasedOnCustomDomain = async ( return await DomainModel.findOne({ customDomain, deleted: false }); }; -const getDomain = async (hostName: string): Promise => { - const isProduction = process.env.NODE_ENV === "production"; - const isSubdomain = hostName.endsWith(`.${process.env.DOMAIN}`); +export async function getDomainFromHost( + hostName: string, +): Promise { + const normalizedHost = normalizeHost(hostName); - if (isProduction && (hostName === process.env.DOMAIN || !isSubdomain)) { - return getDomainBasedOnCustomDomain(hostName); + // Check host cache first + const cached = domainCacheByHost.get(normalizedHost); + if (cached && cached.expiresAt > Date.now()) { + return DomainModel.hydrate(cached.data) as unknown as Domain; } - const [subdomain] = hostName?.split("."); - return getDomainBasedOnSubdomain(subdomain); -}; + let domain: Domain | null = null; + const [subdomain] = normalizedHost.split("."); + const configuredRootDomain = normalizeHost(process.env.DOMAIN || ""); + const isConfiguredSubdomain = configuredRootDomain + ? normalizedHost.endsWith(`.${configuredRootDomain}`) + : false; + + if (isConfiguredSubdomain) { + domain = await getDomainBasedOnSubdomain(subdomain); + } else { + const customDomain = await getDomainBasedOnCustomDomain(normalizedHost); + if (customDomain) { + domain = customDomain; + } else if ( + !configuredRootDomain && + subdomain && + subdomain !== normalizedHost + ) { + // Fallback for local/proxy setups where root domain env may not match host. + domain = await getDomainBasedOnSubdomain(subdomain); + } + } + + if (domain) { + domainCacheByHost.set(normalizedHost, { + data: (domain as any).toObject + ? (domain as any).toObject() + : domain, + expiresAt: Date.now() + TTL, + }); + cacheDomainByName(domain); + } + + return domain; +} + +export function cacheDomainByName(domain: Domain): void { + if (!domain?.name) { + return; + } + + domainCacheByName.set(normalizeDomainName(domain.name), { + data: (domain as any).toObject ? (domain as any).toObject() : domain, + expiresAt: Date.now() + TTL, + }); +} export async function getCachedDomain( - hostName: string, + domainName: string, ): Promise { - const cached = domainCache.get(hostName); + const normalizedDomainName = normalizeDomainName(domainName); + if (!normalizedDomainName) { + return null; + } + + const cached = domainCacheByName.get(normalizedDomainName); if (cached && cached.expiresAt > Date.now()) { return DomainModel.hydrate(cached.data) as unknown as Domain; } - const domain = await getDomain(hostName); + const domain = await getDomainBasedOnSubdomain(normalizedDomainName); if (domain) { - domainCache.set(hostName, { - data: (domain as any).toObject(), - expiresAt: Date.now() + TTL, - }); + cacheDomainByName(domain); } return domain; } -export function invalidateDomainCache(hostName: string): void { - domainCache.delete(hostName); +export function invalidateDomainCache(domainName: string): void { + const normalizedDomainName = normalizeDomainName(domainName); + domainCacheByName.delete(normalizedDomainName); + + // Iterate over host cache and delete entries that have the same domain name + for (const [host, entry] of Array.from(domainCacheByHost.entries())) { + if (normalizeDomainName(entry.data.name) === normalizedDomainName) { + domainCacheByHost.delete(host); + } + } } diff --git a/apps/web/models/Domain.ts b/apps/web/models/Domain.ts index 5c190f49f..cfcd19d64 100644 --- a/apps/web/models/Domain.ts +++ b/apps/web/models/Domain.ts @@ -1,78 +1,9 @@ -import mongoose from "mongoose"; -import SettingsSchema from "./SiteInfo"; -import TypefaceSchema from "./Typeface"; -import constants from "../config/constants"; -import { - Constants, - Features, - Domain as PublicDomain, - Typeface, -} from "@courselit/common-models"; -const { typeface } = constants; +import { Domain as InternalDomain, DomainSchema } from "@courselit/orm-models"; +import mongoose, { Model } from "mongoose"; -export interface Domain extends PublicDomain { - _id: mongoose.Types.ObjectId; - lastEditedThemeId?: string; - features?: Features[]; -} +const DomainModel = + (mongoose.models.Domain as Model | undefined) || + mongoose.model("Domain", DomainSchema); -export const defaultTypeface: Typeface = { - section: "default", - typeface: typeface, - fontWeights: [300, 400, 500, 700], - fontSize: 0, - lineHeight: 0, - letterSpacing: 0, - case: "captilize", -}; - -const DomainSchema = new mongoose.Schema( - { - name: { type: String, required: true, unique: true }, - customDomain: { type: String, unique: true, sparse: true }, - email: { type: String, required: true }, - deleted: { type: Boolean, required: true, default: false }, - settings: SettingsSchema, - themeId: { type: String }, - lastEditedThemeId: { type: String }, - sharedWidgets: { - type: mongoose.Schema.Types.Mixed, - default: {}, - }, - draftSharedWidgets: { - type: mongoose.Schema.Types.Mixed, - default: {}, - }, - typefaces: { - type: [TypefaceSchema], - default: [defaultTypeface], - }, - draftTypefaces: { - type: [TypefaceSchema], - default: [defaultTypeface], - }, - firstRun: { type: Boolean, required: true, default: false }, - tags: { type: [String], default: [] }, - checkSubscriptionStatusAfter: { type: Date }, - quota: new mongoose.Schema({ - mail: new mongoose.Schema({ - daily: { type: Number, default: 0 }, - monthly: { type: Number, default: 0 }, - dailyCount: { type: Number, default: 0 }, - monthlyCount: { type: Number, default: 0 }, - lastDailyCountUpdate: { type: Date, default: Date.now }, - lastMonthlyCountUpdate: { type: Date, default: Date.now }, - }), - }), - features: { - type: [String], - enum: Object.values(Constants.Features), - default: [], - }, - }, - { - timestamps: true, - }, -); - -export default mongoose.models.Domain || mongoose.model("Domain", DomainSchema); +export type { InternalDomain as Domain }; +export default DomainModel; diff --git a/apps/web/models/SiteInfo.ts b/apps/web/models/SiteInfo.ts deleted file mode 100644 index 6e83b465f..000000000 --- a/apps/web/models/SiteInfo.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { SiteInfo, Constants } from "@courselit/common-models"; -import mongoose from "mongoose"; -import MediaSchema from "./Media"; - -const SettingsSchema = new mongoose.Schema({ - title: { type: String }, - subtitle: { type: String }, - logo: MediaSchema, - currencyISOCode: { type: String, maxlength: 3 }, - paymentMethod: { type: String, enum: Constants.paymentMethods }, - stripeKey: { type: String }, - codeInjectionHead: { type: String }, - codeInjectionBody: { type: String }, - stripeSecret: { type: String }, - paytmSecret: { type: String }, - paypalSecret: { type: String }, - mailingAddress: { type: String }, - hideCourseLitBranding: { type: Boolean, default: false }, - razorpayKey: { type: String }, - razorpaySecret: { type: String }, - razorpayWebhookSecret: { type: String }, - lemonsqueezyKey: { type: String }, - lemonsqueezyStoreId: { type: String }, - lemonsqueezyWebhookSecret: { type: String }, - lemonsqueezyOneTimeVariantId: { type: String }, - lemonsqueezySubscriptionMonthlyVariantId: { type: String }, - lemonsqueezySubscriptionYearlyVariantId: { type: String }, - logins: { type: [String], enum: Object.values(Constants.LoginProvider) }, - ssoTrustedDomain: { type: String }, -}); - -export default SettingsSchema; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index 34e7a35a3..d89b6dd2c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,6 +8,21 @@ "start": "next start", "prettier": "prettier --write **/*.ts" }, + "browserslist": { + "production": [ + "chrome >= 109", + "edge >= 109", + "firefox >= 109", + "safari >= 15.4", + "ios_saf >= 15.4", + "not dead" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, "dependencies": { "@better-auth/sso": "^1.4.6", "@courselit/common-logic": "workspace:^", diff --git a/docs/wip/PERFORMANCE_REPORT.md b/docs/wip/PERFORMANCE_REPORT.md index ccfff2bcb..094eab7b3 100644 --- a/docs/wip/PERFORMANCE_REPORT.md +++ b/docs/wip/PERFORMANCE_REPORT.md @@ -6,6 +6,32 @@ After auditing the `apps/web` codebase, I found that the public page rendering p --- +## Priority Summary + +| # | Optimization | Impact | Effort | Status | +| :-: | -------------------------------------- | ----------- | --------- | :-----------: | +| 1 | `React.cache()` on data fetchers | 🔴 Critical | ⬜ Small | ✅ Done | +| 2 | Cache `verify-domain` | 🔴 High | ⬜ Small | ✅ Done | +| 3 | Direct DB calls from Server Components | 🟡 High | 🟨 Medium | ❌ Not viable | +| 4 | Redis caching layer | 🟡 High | 🟨 Medium | ⬜ Phase 2 | +| 5 | Convert client → server components | 🟡 Medium | 🟥 High | ⬜ Phase 2 | +| 6 | Optimize font loading | 🟢 Medium | ⬜ Small | ⬜ Phase 2 | +| 7 | HTTP caching / CDN | 🟢 Medium | ⬜ Small | ⬜ Phase 2 | + +## Task Checklist + +- [x] Add `React.cache()` wrappers for `getPage`, `getSiteInfo`, and `getFullSiteSetup`. +- [x] Add in-memory domain cache (`TTL = 60s`) and use it in `verify-domain`. +- [x] Use cached domain lookup in `/api/graph` and parallelize domain/session/body fetch with `Promise.all`. +- [x] Avoid cross-request mutation by caching plain domain objects and hydrating per request. +- [x] Record decision for item #3: direct DB replacement for GraphQL self-fetch is not viable. +- [ ] Add Redis tenant cache layer with resolver-level invalidation. +- [ ] Refactor public rendering path from broad client boundaries to server components. +- [ ] Load fonts dynamically per tenant theme instead of preloading all families. +- [ ] Add HTTP caching strategy (`revalidate`/`Cache-Control`) and CDN partitioning with `Vary: Host`. + +-- + ## 🔴 1. Eliminate Redundant Data Fetching with `React.cache()` (Highest ROI) **Impact: ~60–70% reduction in server-side render time** @@ -257,29 +283,3 @@ async headers() { ``` For a multi-tenant setup, put a CDN (Cloudflare, CloudFront) in front with **Vary: Host** so each tenant gets its own cache partition. - ---- - -## Priority Summary - -| # | Optimization | Impact | Effort | Status | -| :-: | -------------------------------------- | ----------- | --------- | :-----------: | -| 1 | `React.cache()` on data fetchers | 🔴 Critical | ⬜ Small | ✅ Done | -| 2 | Cache `verify-domain` | 🔴 High | ⬜ Small | ✅ Done | -| 3 | Direct DB calls from Server Components | 🟡 High | 🟨 Medium | ❌ Not viable | -| 4 | Redis caching layer | 🟡 High | 🟨 Medium | ⬜ Phase 2 | -| 5 | Convert client → server components | 🟡 Medium | 🟥 High | ⬜ Phase 2 | -| 6 | Optimize font loading | 🟢 Medium | ⬜ Small | ⬜ Phase 2 | -| 7 | HTTP caching / CDN | 🟢 Medium | ⬜ Small | ⬜ Phase 2 | - -## Task Checklist - -- [x] Add `React.cache()` wrappers for `getPage`, `getSiteInfo`, and `getFullSiteSetup`. -- [x] Add in-memory domain cache (`TTL = 60s`) and use it in `verify-domain`. -- [x] Use cached domain lookup in `/api/graph` and parallelize domain/session/body fetch with `Promise.all`. -- [x] Avoid cross-request mutation by caching plain domain objects and hydrating per request. -- [x] Record decision for item #3: direct DB replacement for GraphQL self-fetch is not viable. -- [ ] Add Redis tenant cache layer with resolver-level invalidation. -- [ ] Refactor public rendering path from broad client boundaries to server components. -- [ ] Load fonts dynamically per tenant theme instead of preloading all families. -- [ ] Add HTTP caching strategy (`revalidate`/`Cache-Control`) and CDN partitioning with `Vary: Host`. diff --git a/packages/orm-models/src/models/domain.ts b/packages/orm-models/src/models/domain.ts index d327e2f2e..b4c2679ad 100644 --- a/packages/orm-models/src/models/domain.ts +++ b/packages/orm-models/src/models/domain.ts @@ -1,9 +1,11 @@ import mongoose from "mongoose"; import { SettingsSchema } from "./site-info"; +import { TypefaceSchema } from "./typeface"; import { Constants, Features, Domain as PublicDomain, + Typeface, } from "@courselit/common-models"; export interface Domain extends PublicDomain { @@ -12,6 +14,16 @@ export interface Domain extends PublicDomain { features?: Features[]; } +export const defaultTypeface: Typeface = { + section: "default", + typeface: "Roboto", + fontWeights: [300, 400, 500, 700], + fontSize: 0, + lineHeight: 0, + letterSpacing: 0, + case: "captilize", +}; + export const DomainSchema = new mongoose.Schema( { name: { type: String, required: true, unique: true }, @@ -29,6 +41,14 @@ export const DomainSchema = new mongoose.Schema( type: mongoose.Schema.Types.Mixed, default: {}, }, + typefaces: { + type: [TypefaceSchema], + default: [defaultTypeface], + }, + draftTypefaces: { + type: [TypefaceSchema], + default: [defaultTypeface], + }, firstRun: { type: Boolean, required: true, default: false }, tags: { type: [String], default: [] }, checkSubscriptionStatusAfter: { type: Date }, diff --git a/packages/orm-models/src/models/site-info.ts b/packages/orm-models/src/models/site-info.ts index 88218a05b..3430532a8 100644 --- a/packages/orm-models/src/models/site-info.ts +++ b/packages/orm-models/src/models/site-info.ts @@ -26,4 +26,5 @@ export const SettingsSchema = new mongoose.Schema({ lemonsqueezySubscriptionMonthlyVariantId: { type: String }, lemonsqueezySubscriptionYearlyVariantId: { type: String }, logins: { type: [String], enum: Object.values(Constants.LoginProvider) }, + ssoTrustedDomain: { type: String }, }); diff --git a/packages/orm-models/src/models/typeface.ts b/packages/orm-models/src/models/typeface.ts new file mode 100644 index 000000000..656f640eb --- /dev/null +++ b/packages/orm-models/src/models/typeface.ts @@ -0,0 +1,27 @@ +import { Typeface } from "@courselit/common-models"; +import mongoose from "mongoose"; + +export const TypefaceSchema = new mongoose.Schema({ + section: { + type: String, + required: true, + enum: ["default", "title", "subtitle", "body", "navigation", "button"], + }, + typeface: String, + fontWeights: [ + { + type: Number, + required: true, + enum: [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000], + }, + ], + fontSize: Number, + lineHeight: Number, + letterSpacing: Number, + case: { + type: String, + required: true, + enum: ["uppercase", "lowercase", "captilize"], + default: "captilize", + }, +}); From 584455c882e74917b93eee172c5b3a964bc86440 Mon Sep 17 00:00:00 2001 From: Rajat Date: Sun, 1 Mar 2026 12:22:57 +0530 Subject: [PATCH 6/8] Added comment --- apps/web/app/verify-domain/resolve-domain.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/verify-domain/resolve-domain.ts b/apps/web/app/verify-domain/resolve-domain.ts index e7593c839..a5c667bc9 100644 --- a/apps/web/app/verify-domain/resolve-domain.ts +++ b/apps/web/app/verify-domain/resolve-domain.ts @@ -1,3 +1,4 @@ +// This is majorly extracted for easier testing of host name resolution import { Domain } from "@/models/Domain"; import { getCachedDomain, getDomainFromHost } from "@/lib/domain-cache"; From 0cc8dcf03ffa6fdeac85350b53ec20ff6517c1f8 Mon Sep 17 00:00:00 2001 From: Rajat Date: Sun, 1 Mar 2026 12:30:16 +0530 Subject: [PATCH 7/8] Prettier fixes --- apps/docs/src/pages/en/website/blocks.md | 10 +++---- apps/docs/src/pages/en/website/themes.md | 36 ++++++++++++------------ 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/docs/src/pages/en/website/blocks.md b/apps/docs/src/pages/en/website/blocks.md index 50f27992d..78ced6dfe 100644 --- a/apps/docs/src/pages/en/website/blocks.md +++ b/apps/docs/src/pages/en/website/blocks.md @@ -36,7 +36,7 @@ You will also see the newly added link on the header itself. 3. Click on the pencil icon against the newly added link to edit it as shown above. 4. Change the label (displayed as text on the header block) and the URL (where the user should be taken upon clicking the label on the header) and click `Done` to save. ![Header edit link](/assets/pages/header-edit-link.png) - + ### [Rich Text](#rich-text) @@ -69,7 +69,7 @@ The rich text block uses the same text editor available elsewhere on the platfor 2. Click on the floating `link` icon to reveal a text input. 3. In the popup text input, enter the URL as shown below and press Enter. ![Create a hyperlink in rich text block](/assets/pages/courselit-text-editor-create-links.gif) - + ### [Hero](#hero) @@ -95,7 +95,7 @@ Following is how it looks on a page. 4. In the button action, enter the URL the user should be taken to upon clicking. a. If the URL is from your own school, use its relative form, i.e., `/courses`. b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`. - + ### [Grid](#grid) @@ -140,7 +140,7 @@ A grid block comes in handy when you want to show some sort of list, for example 4. In the button action, enter the URL the user should be taken to upon clicking. a. If the URL is from your own school, use its relative form, i.e., `/courses`. b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`. - + ### [Featured](#featured) @@ -322,7 +322,7 @@ In the `Design` panel, you can customize: - Maximum width - Vertical padding - Social media links (Facebook, Twitter, Instagram, LinkedIn, YouTube, Discord, GitHub) - + ## [Shared blocks](#shared-blocks) diff --git a/apps/docs/src/pages/en/website/themes.md b/apps/docs/src/pages/en/website/themes.md index 5c704e4f2..2d7bed189 100644 --- a/apps/docs/src/pages/en/website/themes.md +++ b/apps/docs/src/pages/en/website/themes.md @@ -192,14 +192,14 @@ The typography editor lets you customize text styles across your website. These - Header 3: Smaller titles for subsections - Header 4: Small titles for minor sections - Preheader: Introductory text that appears above headers - +
Subheaders - Subheader 1: Primary subheaders for section introductions - Subheader 2: Secondary subheaders for supporting text -
+
Body Text @@ -207,7 +207,7 @@ The typography editor lets you customize text styles across your website. These - Text 1: Main body text for content - Text 2: Secondary body text for supporting content - Caption: Small text for image captions and footnotes -
+
Interactive Elements @@ -215,7 +215,7 @@ The typography editor lets you customize text styles across your website. These - Link: Text for clickable links - Button: Text for buttons and calls-to-action - Input: Text for form fields and search boxes -
+ For each text style, you can customize: @@ -243,7 +243,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize - **Mulish**: A geometric sans-serif with a modern feel - **Nunito**: A well-balanced font with rounded terminals - **Work Sans**: A clean, modern font with a geometric feel - +
Serif Fonts @@ -253,7 +253,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize - **Playfair Display**: An elegant serif font for headings - **Roboto Slab**: A serif variant of Roboto - **Source Serif 4**: A serif font designed for digital reading -
+
Display Fonts @@ -264,7 +264,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize - **Rubik**: A sans-serif with a geometric feel - **Oswald**: A reworking of the classic style - **Bebas Neue**: A display font with a strong personality -
+
Modern Fonts @@ -272,7 +272,7 @@ CourseLit provides a carefully curated selection of professional fonts, organize - **Lato**: A sans-serif font with a warm feel - **PT Sans**: A font designed for public use - **Quicksand**: A display sans-serif with rounded terminals -
+ Each font is optimized for web use and includes multiple weights for flexibility in design. All fonts support Latin characters and are carefully selected for their readability and professional appearance. @@ -290,7 +290,7 @@ The interactives editor allows you to customize the appearance of interactive el - Shadow effects: From None to 2X Large - Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes) - Disabled state: How the button looks when it can't be clicked - +
Link @@ -300,7 +300,7 @@ The interactives editor allows you to customize the appearance of interactive el - Text shadow: Add depth to your links - Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes) - Disabled state: How the link looks when it can't be clicked -
+
Card @@ -309,7 +309,7 @@ The interactives editor allows you to customize the appearance of interactive el - Border style: Choose from various border styles - Shadow effects: Add depth to your cards - Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes) -
+
Input @@ -320,7 +320,7 @@ The interactives editor allows you to customize the appearance of interactive el - Shadow effects: Add depth to your input fields - Custom styles: Add your own custom styles using [supported Tailwind classes](#supported-tailwind-classes) - Disabled state: How the input looks when it can't be used -
+ ### 4. Structure @@ -332,14 +332,14 @@ The structure editor lets you customize the layout of your pages, like section p Page - Maximum width options: - 2XL (42rem): Compact layout - 3XL (48rem): Standard layout - 4XL (56rem): Wide layout - 5XL (64rem): Extra wide layout - 6XL (72rem): Full width layout - +
Section - Horizontal padding: Space on the left and right sides (None to 9X Large) - Vertical padding: Space on the top and bottom (None to 9X Large) -
+ ## Publishing Changes @@ -387,7 +387,7 @@ When adding custom styles to interactive elements, you can use the following Tai - `text-6xl`: 6X large text - `text-7xl`: 7X large text - `text-8xl`: 8X large text - +
Padding @@ -399,7 +399,7 @@ When adding custom styles to interactive elements, you can use the following Tai #### Horizontal Padding - `px-4` to `px-20`: Horizontal padding from 1rem to 5rem -
+
Colors @@ -454,7 +454,7 @@ Variants available: `hover`, `disabled`, `dark` - `ease-out`: Ease out - `ease-in-out`: Ease in and out - `ease-linear`: Linear -
+
Transforms @@ -481,7 +481,7 @@ Variants available: `hover`, `disabled`, `dark` - `scale-110`: 110% scale - `scale-125`: 125% scale - `scale-150`: 150% scale -
+
Shadows From b5215487979f8e68cbd9a060d224213867210f13 Mon Sep 17 00:00:00 2001 From: Rajat Date: Sun, 1 Mar 2026 12:45:59 +0530 Subject: [PATCH 8/8] Upgraded node version --- .github/workflows/code-quality.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index 8a31c22ec..935e6c6b8 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -8,7 +8,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: - node-version: '18.x' + node-version: '24.x' - uses: pnpm/action-setup@v2 with: version: 8