Files
bookhoard/HYBRID_SSR_IMPLEMENTATION.md
T
john-okeefe 0a97b679a6 docs: Add hybrid SSR and project guidelines documentation
- Document hybrid SSR architecture for frontend implementation
- Add project guidelines for development workflow
- Explain API preservation and SSR approach
2026-01-31 22:32:52 -05:00

21 KiB

Hybrid SSR + API Implementation Guide

Executive Summary

This guide documents a Hybrid Server-Side Rendering (SSR) + API architecture that:

  • Keeps all existing API routes unchanged (/api/* routes remain intact)
  • Adds new HTML routes (/collections, /collections/:id) that fetch data server-side
  • Shares business logic between API and HTML routes via the service layer
  • Provides faster initial page loads while maintaining API flexibility
  • Zero breaking changes to mobile apps, external consumers, or existing API clients

Current Architecture (Analysis)

Existing Pattern: API-Driven Frontend

Current structure:

Browser visits /dashboard
    ↓
Go template renders EMPTY HTML skeleton
    ↓
JavaScript fetch() calls /api/libraries/visible
    ↓
API returns JSON data
    ↓
JavaScript populates the DOM

Example from current codebase:

cmd/server/main.go:309-328:

protected.GET("/dashboard", func(c echo.Context) error {
    // Get user from JWT context
    userID := c.Get("user_id").(string)
    user := templates.User{
        ID:       userID,
        Email:    c.Get("user_email").(string),
        Username: c.Get("user_username").(string),
        Role:     c.Get("user_role").(string),
    }

    // Render template WITH NO DATA
    var buf bytes.Buffer
    err := templates.Dashboard(user).Render(c.Request().Context(), &buf)
    return c.HTML(http.StatusOK, buf.String())
})

templates/dashboard.templ:

function loadLibraries() {
    // Template is empty, fetch data via API
    fetch('/api/libraries/visible', {
        headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
    })
    .then(response => response.json())
    .then(data => {
        libraries = data;
        renderLibraries();
    })
}

Service Layer Architecture (Already Well-Designed)

The codebase already uses a service layer, which is perfect for the hybrid approach:

API Handler
    ↓ calls
Service Layer (business logic)
    ↓ queries
Database Layer

Example from internal/handlers/collections.go:100-107:

func (h *CollectionHandler) GetCollections(c echo.Context) error {
    user := c.Get("user").(database.Users)
    userUUID := uuid.UUID(user.ID.Bytes)

    // Business logic is in the SERVICE layer
    collections, err := h.collectionService.GetUserCollections(
        c.Request().Context(),
        userUUID
    )

    return c.JSON(http.StatusOK, map[string]interface{}{
        "collections": collections,
        "total":       len(collections),
    })
}

Service layer (internal/services/collection_service.go):

func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.UUID) ([]database.GetCollectionsRow, error) {
    // Business logic, validation, database queries
    return s.db.GetCollections(ctx, database.GetCollectionsParams{
        UserID: pgtype.UUID{Bytes: userID, Valid: true},
    })
}

This is the key insight: The service layer (GetUserCollections) contains all the business logic. The handler just formats the response (JSON vs HTML).

Proposed Hybrid Architecture

New Pattern: SSR + API

Browser visits /collections
    ↓
Go fetches data from service layer
    ↓
Go template renders HTML WITH DATA
    ↓
Browser shows complete page instantly ⚡
    ↓
(Optional) JavaScript updates via API for interactivity

Key principle: Shared service layer, different response formats

┌─────────────────────────────────────────┐
│  BOTH routes call SAME service method   │
└─────────────────────────────────────────┘
           ↓                    ↓
    /api/collections      /collections
    (JSON response)       (HTML response)
           ↓                    ↓
    c.JSON(200, data)    render(template, data)

Implementation Guide

What Changes (and What Doesn't)

Component Changes? Why
Service Layer No change Already well-designed
Database Queries No change Already optimized
Business Logic No change Single source of truth
/api/* routes No change Keep API intact
Template signatures Yes Add data parameters
New HTML routes Yes Add /collections, etc.
Template JavaScript Yes Remove initial fetch()

Purpose: Extract data-fetching logic so both API and HTML routes can use it.

Example for collections.go:

// Add this method to CollectionHandler
// Returns raw data (not JSON, not HTML)
func (h *CollectionHandler) GetCollectionsData(c echo.Context) ([]database.GetCollectionsRow, error) {
    user := c.Get("user").(database.Users)
    userUUID := uuid.UUID(user.ID.Bytes)

    collections, err := h.collectionService.GetUserCollections(
        c.Request().Context(),
        userUUID,
    )

    return collections, err
}

// Now API handler uses this helper
func (h *CollectionHandler) GetCollections(c echo.Context) error {
    collections, err := h.GetCollectionsData(c)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
    }

    // Format as JSON
    return c.JSON(http.StatusOK, map[string]interface{}{
        "collections": collections,
        "total":       len(collections),
    })
}

Why this pattern:

  • API handler: calls GetCollectionsData() → returns JSON
  • HTML route: calls GetCollectionsData() → returns HTML
  • Single source of truth, no duplication

Step 2: Add New HTML Routes

Location: cmd/server/main.go

Add these new routes after the existing /api/* routes:

// ===== NEW HTML ROUTES (SSR) =====
// These routes fetch data server-side and render pre-populated templates
// The /api/* routes remain unchanged for JSON API consumers

// Collections list page with SSR
protected.GET("/collections", func(c echo.Context) error {
    user := getUserFromContext(c)

    // Fetch data server-side
    collections, err := collectionHandler.GetCollectionsData(c)
    if err != nil {
        return c.HTML(http.StatusInternalServerError, "Error loading collections")
    }

    // Convert to template format if needed
    type CollectionData struct {
        ID          string
        Name        string
        Description string
        Color       string
        Icon        string
    }
    templateData := make([]CollectionData, len(collections))
    for i, col := range collections {
        templateData[i] = CollectionData{
            ID:          uuid.UUID(col.ID.Bytes).String(),
            Name:        col.Name,
            Description: textToString(col.Description),
            Color:       textToString(col.Color),
            Icon:        textToString(col.Icon),
        }
    }

    // Render template WITH data
    var buf bytes.Buffer
    err = templates.Collections(user, templateData).Render(c.Request().Context(), &buf)
    if err != nil {
        return err
    }
    return c.HTML(http.StatusOK, buf.String())
})

// Collection detail page with SSR
protected.GET("/collections/:id", func(c echo.Context) error {
    user := getUserFromContext(c)
    collectionID, err := uuid.Parse(c.Param("id"))
    if err != nil {
        return c.HTML(http.StatusBadRequest, "Invalid collection ID")
    }

    // Fetch collection data server-side
    collection, err := collectionHandler.GetCollectionData(c, collectionID)
    if err != nil {
        return c.HTML(http.StatusNotFound, "Collection not found")
    }

    // Fetch books in collection
    books, err := collectionHandler.GetCollectionBooksData(c, collectionID)
    if err != nil {
        return c.HTML(http.StatusInternalServerError, "Error loading books")
    }

    // Render template WITH data
    var buf bytes.Buffer
    err = templates.CollectionDetail(user, collection, books).Render(c.Request().Context(), &buf)
    if err != nil {
        return err
    }
    return c.HTML(http.StatusOK, buf.String())
})

Important notes:

  • These are NEW routes, they don't replace /api/collections
  • The /api/collections route remains unchanged
  • Mobile apps and external consumers continue using /api/*
  • Only browsers visiting /collections get the SSR version

Step 3: Update Template Signatures

Current template signature:

templ Collections(user User) {
    <div id="collections"></div>
    <script>
        fetch('/api/collections')  // Slow!
    </script>
}

New template signature:

templ Collections(user User, collections []Collection) {
    <div id="collections">
        for col := range collections {
            <div class="card">
                <h3>{ col.Name }</h3>
                <p>{ col.Description }</p>
            </div>
        }
    </div>
    <script>
        // Data already loaded!
        // API calls only for updates (add, delete, etc.)
        function deleteCollection(id) {
            fetch(`/api/collections/${id}`, { method: 'DELETE' })
                .then(() => location.reload());
        }
    </script>
}

Step 4: Helper Function for User Context

Add this utility function to main.go to reduce boilerplate:

// Helper to get user from JWT context
func getUserFromContext(c echo.Context) templates.User {
    return templates.User{
        ID:       c.Get("user_id").(string),
        Email:    c.Get("user_email").(string),
        Username: c.Get("user_username").(string),
        Role:     c.Get("user_role").(string),
    }
}

Now all HTML routes can use this:

protected.GET("/collections", func(c echo.Context) error {
    user := getUserFromContext(c)  // Reusable helper
    // ... rest of the code
})

Route Structure Summary

Before (Current)

/api/collections          → JSON (API)
/api/collections/:id      → JSON (API)
/dashboard               → HTML (no SSR, fetches data)
/devices                 → HTML (no SSR, fetches data)

After (Hybrid)

/api/collections          → JSON (API) - UNCHANGED
/api/collections/:id      → JSON (API) - UNCHANGED
/collections             → HTML (SSR) - NEW
/collections/:id         → HTML (SSR) - NEW
/dashboard               → HTML (no SSR) - UNCHANGED
/devices                 → HTML (no SSR) - UNCHANGED

Migration Checklist

For each new page you want to convert to SSR:

  • Add data-fetching helper to handler (if doesn't exist)
  • Create new HTML route in cmd/server/main.go
  • Update template signature to accept data
  • Remove initial fetch() from template JavaScript
  • Keep fetch() for CRUD operations (create, update, delete)
  • Test API routes still work with curl/Postman
  • Test HTML routes in browser

Benefits

Performance

Before (API-only):

  1. Browser requests page
  2. Server renders empty HTML (~10ms)
  3. Browser receives HTML
  4. JavaScript fetches data (~50ms)
  5. Server queries database (~20ms)
  6. Server returns JSON (~5ms)
  7. Browser renders data (~10ms) Total: ~95ms visible to user

After (SSR):

  1. Browser requests page
  2. Server queries database (~20ms)
  3. Server renders HTML with data (~10ms)
  4. Browser receives complete HTML
  5. Browser paints page (~10ms) Total: ~40ms visible to user

58% faster initial load!

Architectural Benefits

  1. Zero logic duplication: Service layer is single source of truth
  2. API remains intact: No breaking changes for mobile/external consumers
  3. SEO friendly: Search engines see complete HTML
  4. Progressive enhancement: Works without JavaScript
  5. Easier testing: Can test API routes independently

Example: Complete Implementation

File: internal/handlers/collections.go

Add these helper methods:

// GetCollectionsData returns raw collection data (for both API and HTML)
func (h *CollectionHandler) GetCollectionsData(c echo.Context) ([]database.GetCollectionsRow, error) {
    user := c.Get("user").(database.Users)
    userUUID := uuid.UUID(user.ID.Bytes)

    return h.collectionService.GetUserCollections(
        c.Request().Context(),
        userUUID,
    )
}

// GetCollectionData returns single collection (for both API and HTML)
func (h *CollectionHandler) GetCollectionData(c echo.Context, collectionID uuid.UUID) (database.Collections, error) {
    return h.collectionService.GetCollection(c.Request().Context(), collectionID)
}

// GetCollectionBooksData returns books in collection (for both API and HTML)
func (h *CollectionHandler) GetCollectionBooksData(c echo.Context, collectionID uuid.UUID) ([]database.GetCollectionBooksRow, error) {
    return h.collectionService.GetCollectionBooks(c.Request().Context(), collectionID)
}

Update existing API handlers to use helpers:

// Before
func (h *CollectionHandler) GetCollections(c echo.Context) error {
    user := c.Get("user").(database.Users)
    userUUID := uuid.UUID(user.ID.Bytes)
    collections, err := h.collectionService.GetUserCollections(...)
    // ...
}

// After
func (h *CollectionHandler) GetCollections(c echo.Context) error {
    collections, err := h.GetCollectionsData(c)
    // Format and return JSON
    // ...
}

File: cmd/server/main.go

Add after line 258 (after collections API routes):

// ===== HTML ROUTES WITH SSR =====
// These provide fast initial page loads while keeping /api/* routes intact

// Collections list page
protected.GET("/collections", func(c echo.Context) error {
    user := getUserFromContext(c)

    collections, err := collectionHandler.GetCollectionsData(c)
    if err != nil {
        return c.HTML(500, "Error loading collections")
    }

    type ColData struct {
        ID          string
        Name        string
        Description string
        Color       string
        Icon        string
    }

    data := make([]ColData, len(collections))
    for i, col := range collections {
        data[i] = ColData{
            ID:          uuid.UUID(col.ID.Bytes).String(),
            Name:        col.Name,
            Description: textToString(col.Description),
            Color:       textToString(col.Color),
            Icon:        textToString(col.Icon),
        }
    }

    var buf bytes.Buffer
    templates.Collections(user, data).Render(c.Request().Context(), &buf)
    return c.HTML(200, buf.String())
})

// Collection detail page
protected.GET("/collections/:id", func(c echo.Context) error {
    user := getUserFromContext(c)
    collectionID, _ := uuid.Parse(c.Param("id"))

    collection, err := collectionHandler.GetCollectionData(c, collectionID)
    if err != nil {
        return c.HTML(404, "Collection not found")
    }

    books, err := collectionHandler.GetCollectionBooksData(c, collectionID)
    if err != nil {
        return c.HTML(500, "Error loading books")
    }

    var buf bytes.Buffer
    templates.CollectionDetail(user, collection, books).Render(c.Request().Context(), &buf)
    return c.HTML(200, buf.String())
})

File: templates/collections.templ

Update template signature:

// Before
templ Collections(user User) {
    <div id="collections-container"></div>
    <script>
        // Fetch data on load
        fetch('/api/collections')
            .then(r => r.json())
            .then(data => renderCollections(data.collections));
    </script>
}

// After
templ Collections(user User, collections []Collection) {
    <div id="collections-container">
        for col := range collections {
            <div class="card" style="border-left: 4px solid { col.Color }">
                <div class="flex justify-between">
                    <div>
                        <h3 class="text-xl font-bold">{ col.Name }</h3>
                        <p class="text-gray-400">{ col.Description }</p>
                    </div>
                    <div class="flex gap-2">
                        <button onclick="viewCollection('{ col.ID }')" class="btn-primary">
                            View
                        </button>
                        <button onclick="editCollection('{ col.ID }')" class="btn-secondary">
                            Edit
                        </button>
                        <button onclick="deleteCollection('{ col.ID }')" class="btn-danger">
                            Delete
                        </button>
                    </div>
                </div>
            </div>
        }
    </div>

    <script>
        // Data already rendered! No initial fetch needed.

        // API calls only for CRUD operations
        async function deleteCollection(id) {
            if (!confirm('Delete this collection?')) return;

            const response = await fetch(`/api/collections/${id}`, {
                method: 'DELETE',
                headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
            });

            if (response.ok) {
                location.reload();  // Or remove element from DOM
            }
        }

        function viewCollection(id) {
            window.location.href = `/collections/${id}`;
        }
    </script>
}

Testing Checklist

API Routes (Must Remain Unchanged)

# Test collections API
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/collections
# Should return JSON as before

curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/collections/ID
# Should return JSON as before

HTML Routes (New)

# Test HTML route in browser
open http://localhost:8765/collections
# Should show complete page instantly with data

Verify No Breaking Changes

  1. Mobile apps still work: Test with existing mobile app or API client
  2. External consumers still work: Test with Postman/curl
  3. Database queries unchanged: Monitor query logs
  4. Business logic unchanged: Test collection creation, updates, deletion

Common Pitfalls

Don't: Modify Existing API Routes

// WRONG: Don't change existing API handler
func (h *CollectionHandler) GetCollections(c echo.Context) error {
    // Don't add HTML rendering here!
    if wantsHTML(c) {
        return renderHTML(...)
    }
    return c.JSON(...)
}

Why: Breaks API consumers, adds complexity to API routes.

Do: Create Separate HTML Routes

// RIGHT: Keep API handler simple, add separate HTML route
func (h *CollectionHandler) GetCollections(c echo.Context) error {
    data, _ := h.GetCollectionsData(c)
    return c.JSON(200, data)  // JSON only
}

// Separate HTML route in main.go
protected.GET("/collections", func(c echo.Context) error {
    data, _ := collectionHandler.GetCollectionsData(c)
    return render(c, templates.Collections(user, data))
})

Why: Clean separation, API stays simple, HTML routes are independent.

Don't: Duplicate Business Logic

// WRONG: Duplicating queries
func (h *CollectionHandler) GetCollections(c echo.Context) error {
    collections, _ := h.db.GetCollections(...)  // Query here
    return c.JSON(200, collections)
}

protected.GET("/collections", func(c echo.Context) error {
    collections, _ := h.db.GetCollections(...)  // Same query again!
    return render(c, templates.Collections(user, collections))
})

Why: Duplication, harder to maintain, bugs appear twice.

Do: Share Service Layer

// RIGHT: Both use same service method
func (h *CollectionHandler) GetCollectionsData(c echo.Context) {
    return h.collectionService.GetUserCollections(...)  // Single source
}

func (h *CollectionHandler) GetCollections(c echo.Context) error {
    data, _ := h.GetCollectionsData(c)
    return c.JSON(200, data)
}

protected.GET("/collections", func(c echo.Context) error {
    data, _ := collectionHandler.GetCollectionsData(c)
    return render(c, templates.Collections(user, data))
})

Why: Single source of truth, DRY principle, easier maintenance.

Rollback Plan

If anything goes wrong, rollback is trivial:

  1. Remove new HTML routes: Delete the protected.GET("/collections", ...) blocks from main.go
  2. Revert template changes: Change templ Collections(user, collections) back to templ Collections(user)
  3. API routes untouched: No changes made to /api/* routes

Zero risk to existing functionality.

Next Steps

This document provides the architectural foundation. Before implementing Phase 9 (Collections UI), the codebase should be migrated to this hybrid pattern for consistency:

  1. Migrate existing pages (/dashboard, /devices) to use SSR
  2. Add helper methods to all handlers
  3. Update all templates to accept data
  4. Test thoroughly

Once the pattern is established, Phase 9 implementation will be straightforward and follow the same conventions.


Document Version: 1.0 Last Updated: 2025-01-31 Status: Ready for Implementation