Created comprehensive SSR_FIRST_ALPINE_GUIDE.md to establish SSR-first
architecture principles for Alpine.js integration.
New Guide: SSR_FIRST_ALPINE_GUIDE.md
Covers:
- SSR-first principles (state in templates, no fetch in x-init for SSR pages)
- Three page type classifications:
* Type 1: 80% SSR (Collections, Conflicts) - backend provides all data
* Type 2: SSR + Interactive (Dashboard, Admin Library) - SSR + interactivity
* Type 3: 80% JavaScript (Analytics) - x-init fetches all data (intentional)
- The SSR data fetch problem (x-init replacing SSR content)
- DOMContentLoaded cleanup strategies
- Page-by-page strategy for each type
- Authentication & SSR (server-side token injection)
- Verification checklist and testing approach
- Architecture diagram showing data flow
Key Principles:
- ❌ NEVER fetch data in x-init if data is already SSR'd
- ✅ x-init ONLY for setup (event listeners, modals)
- ✅ Data fetch ONLY after user actions
- ✅ State lives in template (x-data), not TypeScript
Updated: COLLECTIONS_CLEANUP_GUIDE.md
Changes:
- Added reference to SSR_FIRST_ALPINE_GUIDE.md as authority
- Removed two-option approach (no more choices)
- Documented that admin library SSR bug is already fixed (commit 1b9bc64)
- Simplified dashboard approach (wrap existing code in initDashboard)
- Simplified docs approach (simple setup, no data fetch)
- Updated summary to reflect completed work
- Added architecture section showing state location
Architecture Clarity:
- Templates: UI state (x-data, x-show)
- Backend: SSR data
- TypeScript: Business logic only
- No hybrid approach - follow SSR-first principles
References:
- SSR_FIRST_ALPINE_GUIDE.md - Complete SSR-first architecture
- ALPINE_COMPLETION_GUIDE.md - Full Alpine.js migration (future goal)
- PROJECT_GUIDELINES.md - Project standards
This establishes a single source of truth for SSR-first Alpine.js
architecture and removes confusion about which approach to use.
628 lines
15 KiB
Markdown
628 lines
15 KiB
Markdown
# 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
|
|
<body class="theme-{ user.Theme }">
|
|
@Header(user, currentPath)
|
|
|
|
<!-- SSR data rendered here -->
|
|
<div>{ collections }</div>
|
|
|
|
<!-- Alpine manages modal state only -->
|
|
<div x-data="{ modalOpen: false }">
|
|
<button @click="modalOpen = true">Open</button>
|
|
<div x-show="modalOpen" x-transition>Modal content</div>
|
|
</div>
|
|
</body>
|
|
```
|
|
|
|
**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
|
|
<body class="theme-{ user.Theme }">
|
|
@Header(user, "/dashboard")
|
|
|
|
<!-- SSR data from backend -->
|
|
{ sections }
|
|
|
|
<!-- Alpine handles drag-drop, library switching -->
|
|
<div x-data="dashboard">
|
|
<!-- Drag-drop areas -->
|
|
<!-- Library selector (triggers data fetch on change, not init) -->
|
|
</div>
|
|
</body>
|
|
```
|
|
|
|
**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
|
|
<body x-data="analytics" x-init="loadAnalytics" class="theme-{ user.Theme }">
|
|
@Header(user, "/analytics")
|
|
|
|
<!-- Empty containers - JavaScript fills them -->
|
|
<div id="reading-stats"></div>
|
|
<div id="device-usage"></div>
|
|
</body>
|
|
```
|
|
|
|
**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
|
|
<!-- ❌ WRONG - SSR content gets replaced -->
|
|
<body x-data="library" x-init="initializeAdmin">
|
|
<!-- Backend rendered this: -->
|
|
{ libraries }
|
|
|
|
<!-- But x-init fetches and replaces it! -->
|
|
</body>
|
|
```
|
|
|
|
### 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
|
|
<!-- ✅ CORRECT - SSR content stays -->
|
|
<body x-data="library" x-init="initializeAdmin">
|
|
<!-- Backend rendered this, it stays: -->
|
|
{ libraries }
|
|
|
|
<!-- x-init only sets up event listeners -->
|
|
</body>
|
|
```
|
|
|
|
### 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
|
|
<!-- templates/admin.templ -->
|
|
<body x-data="admin" x-init="initializeAdmin">
|
|
```
|
|
|
|
#### 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
|
|
<!-- templates/admin.templ -->
|
|
<body>
|
|
<!-- No x-init needed -->
|
|
<button data-action="delete-library">Delete</button>
|
|
</body>
|
|
```
|
|
|
|
### 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
|
|
<!-- templates/dashboard.templ -->
|
|
<body x-data="dashboard" x-init="initDashboard" class="theme-{ user.Theme }">
|
|
```
|
|
|
|
### 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
|
|
<!-- templates/docs.templ -->
|
|
<body x-data="docs" x-init="initializeDocsSearch" class="theme-{ user.Theme }">
|
|
```
|
|
|
|
### 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
|
|
<!-- templates/admin.templ -->
|
|
<script>
|
|
const ws = new WebSocket(`ws://localhost:8765/ws/sync?token={ user.Token }`);
|
|
</script>
|
|
```
|
|
|
|
**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.
|