# SSR-First Alpine.js Architecture Guide ## Overview This guide ensures Alpine.js integration **maintains SSR-first architecture** while providing interactive features. **Goal:** Use Alpine.js for interactivity WITHOUT replacing SSR content on page load. **Complementary to:** `ALPINE_COMPLETION_GUIDE.md` (which covers eliminating manual DOM manipulation) --- ## Table of Contents 1. [SSR-First Principles](#ssr-first-principles) 2. [Page Type Classifications](#page-type-classifications) 3. [The SSR Data Fetch Problem](#the-ssr-data-fetch-problem) 4. [DOMContentLoaded Cleanup](#domcontentloaded-cleanup) 5. [Page-by-Page Strategy](#page-by-page-strategy) 6. [Authentication & SSR](#authentication--ssr) 7. [Verification](#verification) --- ## SSR-First Principles ### Core Rule **❌ NEVER fetch data in x-init if the data is already SSR'd** ### What Each Layer Does | Layer | Responsibility | |-------|---------------| | **Backend (Go)** | SSR initial page load with real data | | **Template (.templ)** | Render SSR data, define UI state with `x-data` | | **Alpine.js** | Manage UI state (modals, dropdowns, transitions) | | **TypeScript** | Pure business logic (API calls, data processing) | ### Three Page Types 1. **80% SSR Pages** (most pages) - Backend provides initial data - Alpine handles modals/dropdowns only - x-init NEVER fetches data 2. **SSR + Interactive Pages** (dashboard, bookshelf) - Backend provides initial data - Alpine handles interactivity (drag-drop, filtering) - x-init ONLY sets up event listeners, never fetches 3. **80% JavaScript Pages** (analytics) - Backend renders empty shell - x-init fetches ALL data on page load - Exception to the rule (intentional design) --- ## Page Type Classifications ### Type 1: 80% SSR Pages (Most Pages) **Examples:** Collections, Conflicts, Queue, Devices, Profile **Characteristics:** - Full SSR data from backend - Alpine for modals/dropdowns only - No data fetch in x-init **Template Pattern:** ```templ @Header(user, currentPath)
{ collections }
Modal content
``` **TypeScript:** ```typescript // Business logic only - no UI state async function deleteCollection(id: string) { await apiDelete(`/collections/${id}`); } Alpine.data("collections", () => ({ deleteCollection, // Business logic only // NO modal state - template handles it })); ``` ### Type 2: SSR + Interactive Pages **Examples:** Dashboard, Bookshelf, Admin Library **Characteristics:** - Backend provides initial data - Alpine manages complex interactivity - x-init sets up event listeners ONLY **Dashboard Template Pattern:** ```templ @Header(user, "/dashboard") { sections }
``` **Dashboard TypeScript:** ```typescript // ❌ WRONG - fetches data on page load, replaces SSR function initDashboard() { fetch('/api/dashboard/sections').then(renderDashboard); } // ✅ CORRECT - sets up event listeners only function initDashboard() { initDragAndDrop(); // Setup event listeners setupLibrarySelect(); // Setup event listener for library switching // NO data fetch - SSR provides initial data } Alpine.data("dashboard", () => ({ initDashboard, })); ``` ### Type 3: 80% JavaScript Pages (Exception) **Examples:** Analytics **Characteristics:** - Backend renders empty shell - x-init fetches ALL data - This is intentional - analytics is a dynamic dashboard **Analytics Template Pattern:** ```templ @Header(user, "/analytics")
``` **Analytics TypeScript:** ```typescript // ✅ CORRECT - analytics is 80% JS by design async function loadAnalytics() { const [statsRes, devicesRes] = await Promise.all([ fetch("/api/analytics/stats"), fetch("/api/analytics/devices"), ]); renderReadingStats(await statsRes.json()); renderDeviceUsage(await devicesRes.json()); } Alpine.data("analytics", () => ({ loadAnalytics, })); ``` --- ## The SSR Data Fetch Problem ### The Bug **❌ BUG:** x-init fetches data and replaces SSR content ```typescript // ❌ WRONG - replaces SSR content on page load function initializeAdmin() { fetch('/api/libraries').then(renderLibraries); // BUG! } ``` ```html { libraries } ``` ### The Fix **✅ Solution:** Remove data fetch from x-init ```typescript // ✅ CORRECT - no data fetch function initializeAdmin() { setupEventListeners(); // Setup only } // Keep fetch for AFTER CRUD operations async function reloadLibraries() { fetch('/api/libraries').then(renderLibraries); // OK after create/delete } ``` ```html { libraries } ``` ### When to Fetch Data ✅ **OK to fetch in x-init:** - Analytics pages (Type 3) - Empty pages that need data - User-driven navigation (not initial page load) ❌ **NOT OK to fetch in x-init:** - Pages with SSR data (Types 1 & 2) - Data that backend already provided - Replacing SSR content on page load ✅ **OK to fetch AFTER user action:** - After create/delete/update operations - After dropdown selection - After form submission --- ## DOMContentLoaded Cleanup ### Problem **DOMContentLoaded listeners run on EVERY page** due to `main.ts` importing all modules. **Example:** ```typescript // web/src/admin.ts document.addEventListener("DOMContentLoaded", () => { initializeAdmin(); // Runs on index page! }); ``` ```typescript // web/src/main.ts import "./admin"; // Imports admin module on ALL pages import "./dashboard"; // Imports dashboard module on ALL pages ``` ### Solution: Two Approaches #### Approach 1: x-init Wrapper (Current Approach) Wrap DOMContentLoaded logic in named function, call via x-init: ```typescript // web/src/admin.ts function initializeAdmin() { setupEventListeners(); } Alpine.data("admin", () => ({ initializeAdmin, })); ``` ```html ``` #### Approach 2: Event Delegation Only (Future) Remove x-init entirely, rely on global event delegation: ```typescript // web/src/admin.ts // NO init function - use global event delegation // Global event listener checks for data-action attributes document.addEventListener("click", (e) => { const action = e.target.closest("[data-action]")?.dataset.action; if (action === "delete-library") deleteLibrary(); }); ``` ```html ``` ### Which to Use? - **Current state:** Use Approach 1 (x-init wrapper) - **Future goal:** Use Approach 2 (event delegation only) - **Migration:** See `ALPINE_COMPLETION_GUIDE.md` for full migration path --- ## Page-by-Page Strategy ### Dashboard **Type:** SSR + Interactive **Current Issues:** - Has DOMContentLoaded (needs wrapper) - Has localStorage redirect logic **Solution:** ```typescript // ✅ Wrap existing logic in initDashboard() function initDashboard() { initDragAndDrop(); // Setup drag-drop document.addEventListener("click", handleDashboardClick); // Event delegation // Check localStorage for saved library const savedLibrary = localStorage.getItem("selectedLibrary"); if (savedLibrary && savedLibrary !== currentLibrary) { window.location.href = `/dashboard?library_id=${savedLibrary}`; } } Alpine.data("dashboard", () => ({ initDashboard, })); ``` ```html ``` ### Admin Library **Type:** SSR + Interactive **Current Issues:** - Has `x-init="initializeLibraryAdmin"` which calls `reloadLibraries()` - This fetches data and replaces SSR content **Solution:** ```typescript // ❌ REMOVE this: function initializeLibraryAdmin() { setupEventListeners(); void reloadLibraries(); // BUG - fetches data! } // ✅ CORRECT: function initializeLibraryAdmin() { setupEventListeners(); // Setup only // No data fetch } // Keep for after CRUD operations: async function reloadLibraries() { const libraries = await apiGet("/libraries"); renderLibraries(libraries.data); // OK after create/delete } ``` ### Collections **Type:** 80% SSR **Current State:** Dead exports removed (see `COLLECTIONS_CLEANUP_GUIDE.md`) **Solution:** - No x-init needed - Use Alpine.store for modal state (see `ALPINE_COMPLETION_GUIDE.md`) - Business logic functions only in TypeScript ### Analytics **Type:** 80% JavaScript **Current State:** Already correct **Solution:** - Keep `x-init="loadAnalytics"` - Data fetch is intentional (analytics is dynamic) ### Docs **Type:** 80% SSR **Current State:** Has DOMContentLoaded **Solution:** ```typescript // ✅ Simple setup only function initializeDocsSearch() { const searchInput = document.getElementById("docs-search"); searchInput?.addEventListener("input", handleDocsSearchInput); } Alpine.data("docs", () => ({ initializeDocsSearch, })); ``` ```html ``` ### Search (in Header) **Type:** Special case **Current Issues:** - Has DOMContentLoaded that runs on ALL pages (via main.ts import) - Uses localStorage for token (not SSR-friendly) **Future:** User plans to revamp search **Current Solution:** - Leave as-is for now - Revisit when search is redesigned - Consider moving token to SSR (see Authentication section below) --- ## Authentication & SSR ### Problem: Token in localStorage ```typescript // ❌ Current - client-side token const token = localStorage.getItem("token"); fetch("/api/libraries", { headers: { Authorization: `Bearer ${token}` }, }); ``` **Issues:** - Not SSR-friendly - Requires client-side storage - Fails if JS disabled ### Solution: Server-Side Token Injection **Backend:** Extract token from HttpOnly cookie ```go // internal/router/helpers.go func getTemplateUserWithTheme(c echo.Context, cfg *config.Config) (templates.User, error) { user := getUserFromSession(c) // Extract JWT from HttpOnly cookie token := "" for _, cookie := range c.Cookies() { if cookie.Name == "token" { token = cookie.Value break } } return templates.User{ ID: user.ID, Username: user.Username, Token: token, // Add token to user struct Theme: user.Theme, }, nil } ``` **Template:** Inject token into WebSocket URL ```templ ``` **Benefits:** - ✅ SSR-compatible - ✅ No localStorage needed - ✅ Works with HttpOnly cookies - ✅ More secure --- ## Verification ### Checklist for Each Page **Type 1 (80% SSR):** - [ ] Backend provides all data - [ ] x-init does NOT fetch data - [ ] x-init only sets up event listeners (if needed) - [ ] Modals use Alpine.store or local x-data **Type 2 (SSR + Interactive):** - [ ] Backend provides initial data - [ ] x-init does NOT fetch data on page load - [ ] Data fetch only after user action (dropdown change, button click) - [ ] Event listeners set up in x-init **Type 3 (80% JS):** - [ ] Backend renders empty shell - [ ] x-init fetches ALL data on page load - [ ] This is intentional and documented ### Testing ```bash # 1. Start application go run . # 2. Open browser DevTools # Network tab -> Disable cache # 3. Load dashboard # Expected: /dashboard HTML response contains full data # Expected: NO /api/dashboard/sections call on page load # 4. Change library dropdown # Expected: /api/dashboard/sections?library_id=XXX call # Expected: Content updates # 5. Load analytics # Expected: /analytics HTML response is empty shell # Expected: /api/analytics/stats call on page load # Expected: /api/analytics/devices call on page load ``` ### Common Bugs to Check **❌ SSR content flashes, then gets replaced:** - x-init is fetching data - Remove data fetch from x-init **❌ API call on page load for SSR page:** - x-init or DOMContentLoaded is calling fetch - Move fetch to after user action **❌ Modal not opening:** - Missing x-data wrapper - Check Alpine DevTools for state **❌ Search not working:** - Check if search input has id="header-search" - Check if initializeSearch() is called --- ## Summary ### Key Rules 1. **State lives in template** (`x-data`, `x-show`) 2. **UI updates automatically** (Alpine reactivity) 3. **No manual DOM manipulation** in TypeScript 4. **Pure business logic** in TypeScript functions 5. **❌ NEVER fetch data in x-init** if data is SSR'd 6. **✅ OK to fetch** after user action or for Type 3 pages ### Architecture ``` ┌─────────────┐ │ Backend │ SSR data │ (Go) │────────────┐ └─────────────┘ │ ▼ ┌──────────┐ │ Template │ x-data state │ (.templ) │────────────┐ └──────────┘ │ ▼ ┌──────────────┐ │ Alpine.js │ UI state │ (Reactivity) │ └──────────────┘ ▲ │ ┌─────────────┐ Business Logic ┌─────────────┐ │ TypeScript │─────────────────│ Browser APIs │ │ (API only) │ │ (fetch, etc) │ └─────────────┘ └─────────────┘ ``` ### Related Guides - **`ALPINE_COMPLETION_GUIDE.md`** - Eliminate manual DOM manipulation - **`COLLECTIONS_CLEANUP_GUIDE.md`** - Fix dead exports and DOMContentLoaded issues - **`PROJECT_GUIDELINES.md`** - Project architecture standards --- ## Next Steps 1. **Classify each page** as Type 1, 2, or 3 2. **Remove data fetches** from x-init on Type 1 & 2 pages 3. **Move token to SSR** (Authentication section) 4. **Test each page** to verify SSR content is not replaced 5. **Document exceptions** (Type 3 pages) **Remember:** SSR-first means backend provides the truth, Alpine handles the interaction.