Files
bookhoard/SSR_FIRST_ALPINE_GUIDE.md
T
john-okeefe ab2e2427cc docs(ssr): create SSR-first Alpine.js guide and update cleanup guide
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.
2026-03-12 18:07:16 -04:00

15 KiB

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
  2. Page Type Classifications
  3. The SSR Data Fetch Problem
  4. DOMContentLoaded Cleanup
  5. Page-by-Page Strategy
  6. Authentication & SSR
  7. 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:

<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:

// 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:

<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:

// ❌ 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:

<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:

// ✅ 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

// ❌ WRONG - replaces SSR content on page load
function initializeAdmin() {
  fetch('/api/libraries').then(renderLibraries);  // BUG!
}
<!-- ❌ 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

// ✅ 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
}
<!-- ✅ 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:

// web/src/admin.ts
document.addEventListener("DOMContentLoaded", () => {
  initializeAdmin();  // Runs on index page!
});
// 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:

// web/src/admin.ts
function initializeAdmin() {
  setupEventListeners();
}

Alpine.data("admin", () => ({
  initializeAdmin,
}));
<!-- 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:

// 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();
});
<!-- 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:

// ✅ 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,
}));
<!-- 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:

// ❌ 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:

// ✅ Simple setup only
function initializeDocsSearch() {
  const searchInput = document.getElementById("docs-search");
  searchInput?.addEventListener("input", handleDocsSearchInput);
}

Alpine.data("docs", () => ({
  initializeDocsSearch,
}));
<!-- 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

// ❌ 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

// 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

<!-- 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

# 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) │
└─────────────┘                 └─────────────┘
  • 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.