# 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:** ```go 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:** ```javascript 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:** ```go 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):** ```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() | ### Step 1: Add Helper Methods to Handlers (Optional but Recommended) **Purpose:** Extract data-fetching logic so both API and HTML routes can use it. **Example for collections.go:** ```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:** ```go // ===== 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 templ Collections(user User) {
} ``` **New template signature:** ```templ templ Collections(user User, collections []Collection) {
for col := range collections {

{ col.Name }

{ col.Description }

}
} ``` ### Step 4: Helper Function for User Context **Add this utility function to main.go to reduce boilerplate:** ```go // 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:** ```go 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:** ```go // 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:** ```go // 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):** ```go // ===== 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:** ```templ // Before templ Collections(user User) {
} // After templ Collections(user User, collections []Collection) {
for col := range collections {

{ col.Name }

{ col.Description }

}
} ``` ## Testing Checklist ### API Routes (Must Remain Unchanged) ```bash # 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) ```bash # 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 ```go // 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 ```go // 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 ```go // 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 ```go // 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