12 KiB
Route Group Splitting — Optimization for Org Landing Pages
Problem
When a user opens an organization via its subdomain (e.g. myorg.classroomio.com), the entire dashboard application was loaded before the landing page could render. This includes heavy dependencies like UpgradeModal, PageLoadProgress, appInitApi, authClient, and all theme components — none of which are needed to display a public landing page.
The result: slow initial load for org landing pages, which should be lightweight and fast.
Solution: SvelteKit Route Groups
Instead of deploying a separate app (which conflicts with the single-domain self-hosted architecture), we split routes into route groups with separate layout chains. This leverages SvelteKit's built-in code splitting — each group only loads the JS it needs.
Route Group Structure
routes/
├── +layout.svelte # Ultra-thin: global CSS, MetaTags, ModeWatcher, Snackbar
├── +layout.server.ts # Root server layout: org detection (getOrgSiteInfo)
├── +page.svelte # Root page: branches between org landing page and dashboard entry
├── _dashboard-entry.svelte # Dynamically imported: auth + setupApp for non-org root visits
│
├── (org-site)/ # Public org pages — lightweight layout
│ ├── +layout.svelte # Org store setup + optional auth (no login redirect)
│ ├── +layout.ts # Passthrough load
│ ├── courses/ # /courses — public course listing
│ └── course/[slug]/ # /course/[slug] — individual course page + enrollment
│
├── (app)/ # Authenticated dashboard — full layout
│ ├── +layout.svelte # Auth session, appInitApi.setupApp(), UpgradeModal, etc.
│ ├── +layout.ts # Passthrough load
│ ├── courses/ # Course management
│ ├── org/ # Org settings
│ ├── lms/ # Student LMS
│ ├── cohorts/ # Cohorts
│ └── home/ # Dashboard home
│
├── (auth)/ # Auth pages (login, signup, etc.)
├── invite/ # Invite handling
└── api/ # API routes
Key Architecture Decisions
1. Root Layout is Ultra-Thin
+layout.svelte only contains:
- Global CSS import
MetaTags(with merge from page data)ModeWatcher(dark mode)Snackbar
No auth logic, no org-specific logic, no heavy component imports. This ensures the shared root bundle is minimal for all routes.
2. Root Page (+page.svelte) Branches by Context
The / route handles two completely different experiences:
- Org site (
data.isOrgSite === true): SSR-fetches public courses in+page.server.ts, then dynamically imports the theme component viaimportThemeComponent(). Sets up org stores (currentOrg,globalStore). - Non-org site (
data.isOrgSite === false): Dynamically imports_dashboard-entry.svelte, which handles auth session checking andappInitApi.setupApp()for redirect logic.
The dynamic imports are critical — they prevent the dashboard's auth dependencies from being bundled with the org landing page, and vice versa.
3. (org-site) Layout — Lightweight with Optional Auth
(org-site)/+layout.svelte responsibilities:
- Set up org stores from server data (
currentOrg,globalStore.isOrgSite, theme) - Set user store from
data.localsif available - Check auth session via
authClient.useSession() - When session is ready, call
appInitApi.setupApp()to load full account details - Does NOT redirect to
/login— these pages are public
The auth handling is needed so that logged-in users see the correct state (e.g. "Go to LMS" vs "Login" button in the nav). setupApp loads the user's organizations and roles, which populates isOrgStudent and basePath.
routeUserToNextPage inside setupApp already handles org-site public routes — it skips redirects when isPublicRoute(path) && (path !== '/' || isOrgSite).
4. (app) Layout — Full Auth + App Initialization
(app)/+layout.svelte responsibilities:
authClient.useSession()with redirect to/loginfor unauthenticated usersappInitApi.setupApp()for full account loadingUpgradeModal,PageLoadProgress,PageRestricted- Org store setup from server data
This layout carries all the heavy dashboard dependencies, which are only loaded for authenticated routes.
5. Dynamic Theme Imports
Previously, all three landing page themes (bold, classic, minimal) were statically imported, meaning all theme code was bundled even when only one was used.
Now importThemeComponent() in $features/org/utils/landing-page.ts uses dynamic imports:
export function importThemeComponent(theme: LandingPageThemeKey) {
switch (theme) {
case 'bold':
return import('@cio/ui/custom/org-landing-page/bold.svelte');
case 'classic':
return import('@cio/ui/custom/org-landing-page/classic.svelte');
default:
return import('@cio/ui/custom/org-landing-page/minimal.svelte');
}
}
Static imports of all themes are preserved in $features/org/utils/landing-page-components.ts — this file is only imported by the dashboard's landing page editor where all themes are needed for preview.
6. SSR for Public Course Data
+page.server.ts fetches public courses server-side for org sites, so the landing page renders with data immediately instead of showing a loading state while fetching client-side.
Store Refactoring: isOrgStudent
Before
isOrgStudentlived in$lib/utils/store/org.tsglobalStorehad anisStudentfield- Three separate
$effectblocks in+page.svelte,(org-site)/+layout.svelte, and(app)/+layout.sveltemanually syncedisOrgStudent→globalStore.isStudent isStudentExperiencederived fromglobalStore.isStudent
This was fragile — if any sync $effect was removed (as happened during refactoring), isStudentExperience and basePath would break.
After
isOrgStudentmoved to$lib/utils/store/app.ts, derived directly fromcurrentOrg.roleIdglobalStore.isStudentremoved entirelyisStudentExperiencenow derives from[globalStore, isOrgStudent]directly — no manual sync needed- All
$effectsync blocks removed from layouts and root page - All component reads of
$globalStore.isStudentreplaced with$isOrgStudent
One source of truth, no manual sync, no fragile $effect chains.
Auth Flow — Centralized in Root Layout
Auth session checking and appInitApi.setupApp() live in the root layout (+layout.svelte). This is the single solution that handles auth for all routes — no per-group duplication, no route audits.
Why centralized?
An earlier iteration put auth in each group layout ((app), (org-site), _dashboard-entry). This broke when new route groups (like (auth)) didn't have auth handling — authenticated users on /login or /signup would see infinite spinners instead of being redirected. Centralizing eliminates that class of bugs entirely.
What the root layout does
authClient.useSession()— checks for an active session on every route- When session is ready, calls
appInitApi.setupApp()— loads full account (profile, orgs, roles) setupApp→routeUserToNextPage()handles redirects based on context:- On
/login,/signup,/onboarding, or/: redirects to/lmsor/org/[slug]based on role - On public org-site routes (
/courses,/course/[slug]): skips redirect (user stays on page with correct login state) - On other public routes: skips redirect
- On
- Sets up user store, org stores, and theme from server data
- No redirect to
/login— that's the(app)layout's job (protected routes only)
What each group layout does
(app)/+layout.svelte: Redirects to/loginif unauthenticated (protected routes). RendersUpgradeModal,PageLoadProgress,PageRestricted.(org-site)/+layout.svelte:PageRestrictedgating only. No auth logic — root handles it.(auth)group: No layout file needed. Root layout handles session →setupApp→ redirect away if already authenticated.
Flow: login on org site
- User logs in at
/login→ redirected to/ - Root layout detects session, calls
setupApp→ loads account details routeUserToNextPageseesisPublicRoute('/') && isOrgSite→ skips redirect- User sees landing page with correct login state ("Go to LMS" button)
Flow: login on non-org site
- User visits
/→ root layout callssetupApp routeUserToNextPageredirects to/lms,/org/[slug], or/onboardingbased on role+page.svelteshows spinner while redirect happens
Flow: authenticated user hits /login
- Root layout detects session, calls
setupApp routeUserToNextPageseesshouldRedirectOnAuth('/login')→ true- Redirects to
/lmsor/org/[slug]
Files Changed
Store layer
apps/dashboard/src/lib/utils/store/app.ts— addedisOrgStudent, removedglobalStore.isStudent, rewroteisStudentExperienceapps/dashboard/src/lib/utils/store/org.ts— removedisOrgStudentexportapps/dashboard/src/lib/features/app/init.svelte.ts— updatedisOrgStudentimport path
Layout layer
apps/dashboard/src/routes/+layout.svelte— stripped to minimalapps/dashboard/src/routes/+page.svelte— branching logic for org vs non-orgapps/dashboard/src/routes/_dashboard-entry.svelte— new, handles non-org authapps/dashboard/src/routes/(org-site)/+layout.svelte— org store setup + optional authapps/dashboard/src/routes/(app)/+layout.svelte— full auth + app init
Component layer (isOrgStudent migration)
features/course/pages/exercise.sveltefeatures/course/pages/lesson.sveltefeatures/course/pages/attendance.sveltefeatures/course/components/exercise/view-mode.sveltefeatures/course/components/lesson/content-list.sveltefeatures/course/components/lesson/content-navigation-actions.sveltefeatures/course/components/lesson/content-section-list.sveltefeatures/course/components/lesson/lesson-page-edit-header.sveltefeatures/course/components/sidebar/course-sidebar.svelteroutes/(app)/courses/[id]/exercises/[exerciseId]/+page.svelte
Landing page optimization
features/org/utils/landing-page.ts— dynamicimportThemeComponent()features/org/utils/landing-page-components.ts— new, static imports for editor onlyroutes/+page.server.ts— SSR course data for org sites
Remaining Work / Known Issues
- Compliance utils (
isSelfPacedLikeCourse): Several components (exercise.svelte,view-mode.svelte,content-list.svelte,content-section-list.svelte) hadisSelfPacedLikeCoursefromcompliance-utils.tsmixed into theisOrgStudentcommit. These have been reverted to inline=== 'SELF_PACED'checks and should be re-introduced in a dedicated compliance commit. $inspectandconsole.logcleanup:(app)/+layout.sveltecontains$inspect('session', $session)and env-loggingconsole.logcalls that should be removed before production.- Bundle size verification: Run
pnpm --filter @cio/dashboard buildand compare chunk sizes for org-site vs app routes to confirm the code splitting is effective. (org-site)course pages:courses/+page.svelteandcourse/[slug]/+page.sveltestill statically import nav/hero components from@cio/ui/custom/org-landing-page. These could be dynamically imported for further optimization.