- Document hybrid SSR architecture for frontend implementation - Add project guidelines for development workflow - Explain API preservation and SSR approach
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() |
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:
// 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/collectionsroute remains unchanged - Mobile apps and external consumers continue using
/api/* - Only browsers visiting
/collectionsget 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):
- Browser requests page
- Server renders empty HTML (~10ms)
- Browser receives HTML
- JavaScript fetches data (~50ms)
- Server queries database (~20ms)
- Server returns JSON (~5ms)
- Browser renders data (~10ms) Total: ~95ms visible to user
After (SSR):
- Browser requests page
- Server queries database (~20ms)
- Server renders HTML with data (~10ms)
- Browser receives complete HTML
- Browser paints page (~10ms) Total: ~40ms visible to user
58% faster initial load!
Architectural Benefits
- Zero logic duplication: Service layer is single source of truth
- API remains intact: No breaking changes for mobile/external consumers
- SEO friendly: Search engines see complete HTML
- Progressive enhancement: Works without JavaScript
- 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
- Mobile apps still work: Test with existing mobile app or API client
- External consumers still work: Test with Postman/curl
- Database queries unchanged: Monitor query logs
- 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:
- Remove new HTML routes: Delete the
protected.GET("/collections", ...)blocks from main.go - Revert template changes: Change
templ Collections(user, collections)back totempl Collections(user) - 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:
- Migrate existing pages (
/dashboard,/devices) to use SSR - Add helper methods to all handlers
- Update all templates to accept data
- 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