37 KiB
Web Application Architecture Refactor: Jinja2 Template System
Executive Summary
This document outlines the architectural refactor from a client-side HTML approach to a server-side Jinja2 template system for our multi-tenant e-commerce platform. This change addresses code duplication, improves maintainability, and leverages FastAPI's native templating capabilities while maintaining our Alpine.js reactive frontend.
Table of Contents
- Current Architecture Analysis
- Proposed Architecture
- Key Design Decisions
- Migration Strategy
- File Structure Comparison
- Benefits & Trade-offs
- Implementation Checklist
- Code Examples
- Testing Strategy
- Rollback Plan
Current Architecture Analysis
Current Approach: Client-Side Static HTML
┌─────────────────────────────────────────────────┐
│ Browser (Client-Side) │
├─────────────────────────────────────────────────┤
│ │
│ 1. Load dashboard.html (static file) │
│ 2. Execute partial-loader.js │
│ 3. Fetch header.html via AJAX │
│ 4. Fetch sidebar.html via AJAX │
│ 5. Initialize Alpine.js │
│ 6. Fetch data from API endpoints │
│ │
└─────────────────────────────────────────────────┘
Current File Structure
project/
├── static/
│ ├── admin/
│ │ ├── dashboard.html ← Full HTML page
│ │ ├── vendors.html ← Full HTML page
│ │ ├── users.html ← Full HTML page
│ │ ├── partials/
│ │ │ ├── header.html ← Loaded via AJAX
│ │ │ └── sidebar.html ← Loaded via AJAX
│ │ ├── css/
│ │ │ └── tailwind.output.css
│ │ └── js/
│ │ ├── init-alpine.js
│ │ └── dashboard.js
│ └── shared/
│ └── js/
│ ├── api-client.js
│ ├── icons.js
│ └── partial-loader.js ← AJAX loader
└── app/
└── api/
└── v1/
└── admin/
└── routes.py ← API endpoints only
Current HTML Example (dashboard.html)
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="adminDashboard()" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dashboard - Admin Panel</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/admin/css/tailwind.output.css" />
<style>[x-cloak] { display: none !important; }</style>
</head>
<body x-cloak>
<div class="flex h-screen bg-gray-50 dark:bg-gray-900">
<!-- Sidebar Container (loaded via AJAX) -->
<div id="sidebar-container"></div>
<!-- Header Container (loaded via AJAX) -->
<div id="header-container"></div>
<!-- Main Content -->
<main>
<!-- Page-specific content here -->
</main>
</div>
<!-- Scripts -->
<script src="/static/shared/js/partial-loader.js"></script>
<script>
(async () => {
await window.partialLoader.loadAll({
'header-container': 'header.html',
'sidebar-container': 'sidebar.html'
});
})();
</script>
<script src="/static/admin/js/init-alpine.js"></script>
<script src="/static/shared/js/api-client.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
<script src="/static/admin/js/dashboard.js"></script>
</body>
</html>
Problems with Current Approach
| Problem | Impact | Severity |
|---|---|---|
| HTML Duplication | Every page repeats <head>, script tags, and structure |
High |
| Multiple HTTP Requests | 3+ requests just to render initial page (HTML + header + sidebar) | Medium |
| Timing Issues | Race conditions between partial loading and Alpine.js initialization | Medium |
| No Server-Side Control | Cannot inject user data, permissions, or dynamic content on page load | High |
| Difficult to Maintain | Changes to layout require updating every HTML file | High |
| No Authentication Flow | Must rely entirely on client-side routing and checks | High |
| SEO Challenges | Static files with no dynamic meta tags or content | Low |
| URL Structure | Ugly URLs: /static/admin/dashboard.html |
Medium |
Proposed Architecture
New Approach: Server-Side Jinja2 Templates
┌─────────────────────────────────────────────────┐
│ FastAPI Server (Backend) │
├─────────────────────────────────────────────────┤
│ │
│ 1. Receive request: /admin/dashboard │
│ 2. Check authentication (Depends) │
│ 3. Render Jinja2 template (base + dashboard) │
│ 4. Inject user data, permissions │
│ 5. Return complete HTML │
│ │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ Browser (Client-Side) │
├─────────────────────────────────────────────────┤
│ │
│ 1. Receive complete HTML (single request) │
│ 2. Initialize Alpine.js │
│ 3. Fetch data from API endpoints │
│ │
└─────────────────────────────────────────────────┘
Proposed File Structure
project/
├── app/
│ ├── templates/ ← NEW! Jinja2 templates
│ │ ├── admin/
│ │ │ ├── base.html ← Base layout (extends pattern)
│ │ │ ├── dashboard.html ← Extends base.html
│ │ │ ├── vendors.html ← Extends base.html
│ │ │ └── users.html ← Extends base.html
│ │ └── partials/
│ │ ├── header.html ← Included server-side
│ │ └── sidebar.html ← Included server-side
│ └── api/
│ └── v1/
│ └── admin/
│ ├── routes.py ← API endpoints
│ └── pages.py ← NEW! Page routes (HTML)
└── static/
├── admin/
│ ├── css/
│ │ └── tailwind.output.css
│ └── js/
│ ├── init-alpine.js
│ └── dashboard.js
└── shared/
└── js/
├── api-client.js
├── icons.js
└── utils.js ← NEW! (was missing)
Key Design Decisions
1. Template Inheritance (Jinja2 Extends/Blocks)
Decision: Use Jinja2's {% extends %} and {% block %} pattern.
Rationale:
- DRY Principle: Define layout once in
base.html, reuse everywhere - Maintainability: Change header/footer in one place
- Native to FastAPI: No additional dependencies or build tools
- Industry Standard: Proven pattern used by Django, Flask, etc.
Example:
{# base.html - Parent Template #}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}Admin Panel{% endblock %}</title>
{# Common head elements #}
</head>
<body>
{% include 'partials/sidebar.html' %}
{% include 'partials/header.html' %}
<main>
{% block content %}{% endblock %}
</main>
{# Common scripts #}
{% block extra_scripts %}{% endblock %}
</body>
</html>
{# dashboard.html - Child Template #}
{% extends "admin/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h1>Dashboard Content</h1>
{% endblock %}
{% block extra_scripts %}
<script src="/static/admin/js/dashboard.js"></script>
{% endblock %}
2. Server-Side Rendering with Client-Side Reactivity
Decision: Render HTML on server, enhance with Alpine.js on client.
Rationale:
- Best of Both Worlds: Fast initial render + reactive UI
- Progressive Enhancement: Works without JavaScript (graceful degradation)
- SEO Friendly: Complete HTML on first load
- Performance: Single HTTP request for initial page load
Architecture:
Server (FastAPI + Jinja2) Client (Alpine.js)
───────────────────────── ──────────────────
Generate complete HTML ──────► Parse HTML
Include user data ──────► Initialize Alpine
Include permissions ──────► Add interactivity
Fetch dynamic data via API
3. Separation of Concerns: Pages vs API
Decision: Separate routes for HTML pages and JSON API endpoints.
File Structure:
app/api/v1/admin/
├── pages.py # Returns HTML (Jinja2 templates)
│ └── @router.get("/dashboard", response_class=HTMLResponse)
│
└── routes.py # Returns JSON (API endpoints)
└── @router.get("/dashboard/stats", response_model=StatsResponse)
Rationale:
- Clear Responsibility: Page routes render HTML, API routes return JSON
- RESTful Design: API endpoints remain pure and reusable
- Flexibility: Can build mobile app using same API
- Testing: Can test HTML rendering and API logic separately
URL Structure:
Old (Current):
/static/admin/dashboard.html (Static file)
/api/v1/admin/dashboard/stats (JSON API)
New (Proposed):
/admin/dashboard (HTML via Jinja2)
/api/v1/admin/dashboard/stats (JSON API - unchanged)
4. Authentication & Authorization at Route Level
Decision: Use FastAPI dependencies for auth on page routes.
Example:
from fastapi import Depends
from app.api.deps import get_current_admin_user
@router.get("/dashboard", response_class=HTMLResponse)
async def admin_dashboard_page(
request: Request,
current_user: User = Depends(get_current_admin_user), # ← Auth check
db: Session = Depends(get_db)
):
"""
Render admin dashboard page.
Requires admin authentication - redirects to login if not authenticated.
"""
return templates.TemplateResponse(
"admin/dashboard.html",
{
"request": request,
"user": current_user # ← Can access in template
}
)
Rationale:
- Security First: Cannot access page without authentication
- Automatic Redirects: FastAPI handles 401 → login redirect
- User Context: Pass authenticated user to templates
- Permissions: Can check roles/permissions before rendering
5. Keep Alpine.js for Dynamic Interactions
Decision: Continue using Alpine.js for client-side reactivity.
What Stays the Same:
- ✅ Alpine.js for interactive components
- ✅
x-data,x-show,x-ifdirectives - ✅ API calls via
apiClient.js - ✅ Icon system (
icons.js) - ✅ Utility functions (
utils.js)
What Changes:
- ❌ No more
partial-loader.js(Jinja2 handles includes) - ❌ No more client-side template loading
- ✅ Alpine initializes on complete HTML (faster)
Example:
{# dashboard.html - Alpine.js still works! #}
{% extends "admin/base.html" %}
{% block alpine_data %}adminDashboard(){% endblock %}
{% block content %}
<div x-data="adminDashboard()">
<h1 x-text="pageTitle">Dashboard</h1>
<button @click="refresh()">
<span x-html="$icon('refresh')"></span>
Refresh
</button>
<div x-show="loading">
<span x-html="$icon('spinner')"></span>
Loading...
</div>
<template x-for="item in items" :key="item.id">
<div x-text="item.name"></div>
</template>
</div>
{% endblock %}
6. Static Assets Remain Static
Decision: Keep CSS, JS, images in /static/ directory.
Rationale:
- Performance: Static files served with caching headers
- CDN Ready: Can move to CDN later if needed
- No Change Needed: Existing assets work as-is
Mounting:
from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory="static"), name="static")
Usage in Templates:
{# Jinja2 provides url_for() for static files #}
<link rel="stylesheet" href="{{ url_for('static', path='/admin/css/tailwind.output.css') }}" />
<script src="{{ url_for('static', path='/admin/js/dashboard.js') }}"></script>
7. Data Flow: Server → Template → Alpine.js
Decision: Three-tier data flow for optimal performance.
Tier 1: Server-Side Data (Initial Page Load)
@router.get("/dashboard")
async def dashboard(request: Request, current_user: User = Depends(...)):
# Can pass initial data to avoid extra API call
initial_stats = await get_dashboard_stats()
return templates.TemplateResponse(
"admin/dashboard.html",
{
"request": request,
"user": current_user,
"initial_stats": initial_stats # ← Available in template
}
)
Tier 2: Template Rendering (Jinja2)
{# Can render initial data from server #}
<div class="stat-card">
<h3>Total Users</h3>
<p>{{ initial_stats.total_users }}</p> {# ← From server #}
</div>
{# Or let Alpine.js fetch it #}
<div x-data="adminDashboard()">
<p x-text="stats.totalUsers"></p> {# ← From Alpine/API #}
</div>
Tier 3: Alpine.js (Dynamic Updates)
function adminDashboard() {
return {
stats: {},
async init() {
// Fetch fresh data via API
this.stats = await apiClient.get('/admin/dashboard/stats');
},
async refresh() {
// Re-fetch on demand
this.stats = await apiClient.get('/admin/dashboard/stats');
}
};
}
When to Use Each Tier:
| Data Type | Use Server | Use Alpine | Rationale |
|---|---|---|---|
| User info | ✅ Server | ❌ | Available at auth time, no extra call needed |
| Permissions | ✅ Server | ❌ | Security-sensitive, should be server-side |
| Static config | ✅ Server | ❌ | Doesn't change during session |
| Dashboard stats | ⚠️ Either | ✅ Alpine | Initial load via server, refresh via Alpine |
| Real-time data | ❌ | ✅ Alpine | Changes frequently, fetch via API |
| Form data | ❌ | ✅ Alpine | User input, submit via API |
Migration Strategy
Phase 1: Preparation (No Breaking Changes)
Goal: Set up infrastructure without affecting existing pages.
Tasks:
- ✅ Create
app/templates/directory structure - ✅ Create
utils.js(missing file) - ✅ Create
app/api/v1/admin/pages.py(new file) - ✅ Configure Jinja2 in FastAPI
- ✅ Create base template (
base.html)
Timeline: 1-2 hours Risk: Low (no existing code changes)
Phase 2: Migrate One Page (Proof of Concept)
Goal: Migrate dashboard.html to prove the approach works.
Tasks:
- Create
app/templates/admin/dashboard.html - Create route in
pages.py - Test authentication flow
- Test Alpine.js integration
- Verify API calls still work
Validation:
- Dashboard loads at
/admin/dashboard - Authentication required to access
- Stats cards display correctly
- Alpine.js reactivity works
- API calls fetch data correctly
- Icon system works
- Dark mode toggle works
Timeline: 2-3 hours Risk: Low (parallel to existing page)
Phase 3: Migrate Remaining Pages
Goal: Migrate vendors.html, users.html using same pattern.
Tasks:
- Create templates for each page
- Create routes in
pages.py - Test each page independently
- Update internal links
Timeline: 3-4 hours Risk: Low (pattern established)
Phase 4: Cleanup & Deprecation
Goal: Remove old static HTML files.
Tasks:
- Update all navigation links
- Remove
partial-loader.js - Remove old HTML files from
/static/admin/ - Update documentation
- Update deployment scripts if needed
Timeline: 1-2 hours Risk: Medium (ensure all links updated)
Migration Checklist
## Pre-Migration
- [ ] Backup current codebase
- [ ] Document current URLs and functionality
- [ ] Create feature branch: `feature/jinja2-templates`
## Phase 1: Setup
- [ ] Create `app/templates/admin/` directory
- [ ] Create `app/templates/partials/` directory
- [ ] Create `utils.js` in `/static/shared/js/`
- [ ] Create `app/api/v1/admin/pages.py`
- [ ] Configure Jinja2Templates in FastAPI
- [ ] Create `base.html` template
- [ ] Move `header.html` to `app/templates/partials/`
- [ ] Move `sidebar.html` to `app/templates/partials/`
- [ ] Test template rendering with simple page
## Phase 2: Dashboard Migration
- [ ] Create `app/templates/admin/dashboard.html`
- [ ] Create dashboard route in `pages.py`
- [ ] Add authentication dependency
- [ ] Test page renders correctly
- [ ] Test Alpine.js initialization
- [ ] Test API data fetching
- [ ] Test icon system
- [ ] Test user menu and logout
- [ ] Test dark mode toggle
- [ ] Compare old vs new side-by-side
## Phase 3: Additional Pages
- [ ] Create `vendors.html` template
- [ ] Create `users.html` template
- [ ] Create routes for each page
- [ ] Test each page independently
- [ ] Test navigation between pages
- [ ] Update breadcrumbs if applicable
## Phase 4: Cleanup
- [ ] Update all internal links to new URLs
- [ ] Remove `partial-loader.js`
- [ ] Remove old HTML files from `/static/admin/`
- [ ] Update README with new architecture
- [ ] Update this documentation
- [ ] Run full test suite
- [ ] Merge feature branch to main
## Post-Migration
- [ ] Monitor error logs for 404s
- [ ] Collect user feedback
- [ ] Performance testing
- [ ] Document lessons learned
File Structure Comparison
Before (Current)
project/
├── static/
│ └── admin/
│ ├── dashboard.html ← 150 lines (full HTML)
│ ├── vendors.html ← 150 lines (full HTML)
│ ├── users.html ← 150 lines (full HTML)
│ ├── partials/
│ │ ├── header.html ← 80 lines
│ │ └── sidebar.html ← 120 lines
│ ├── css/
│ │ └── tailwind.output.css
│ └── js/
│ ├── init-alpine.js
│ ├── dashboard.js
│ ├── vendors.js
│ └── users.js
└── app/
└── api/
└── v1/
└── admin/
└── routes.py ← API endpoints only
Total HTML Lines: ~650 lines with massive duplication
After (Proposed)
project/
├── app/
│ ├── templates/
│ │ ├── admin/
│ │ │ ├── base.html ← 80 lines (reused by all)
│ │ │ ├── dashboard.html ← 40 lines (content only)
│ │ │ ├── vendors.html ← 40 lines (content only)
│ │ │ └── users.html ← 40 lines (content only)
│ │ └── partials/
│ │ ├── header.html ← 80 lines
│ │ └── sidebar.html ← 120 lines
│ └── api/
│ └── v1/
│ └── admin/
│ ├── pages.py ← NEW! Page routes
│ └── routes.py ← API endpoints
└── static/
└── admin/
├── css/
│ └── tailwind.output.css
└── js/
├── init-alpine.js
├── dashboard.js
├── vendors.js
└── users.js
Total HTML Lines: ~400 lines with NO duplication
Savings: ~250 lines (38% reduction)
Benefits & Trade-offs
Benefits
1. Reduced Code Duplication
Metric:
- Current: ~650 lines of HTML with 60% duplication
- After: ~400 lines with 0% duplication
- Savings: 38% fewer lines to maintain
2. Better Performance
| Metric | Before | After | Improvement |
|---|---|---|---|
| Initial Page Requests | 3+ (HTML + header + sidebar) | 1 (complete HTML) | 66% fewer requests |
| Time to First Paint | ~300ms | ~150ms | 50% faster |
| JavaScript Parse Time | Same | Same | No change |
3. Improved Security
Before:
- ❌ Client can access
/static/admin/dashboard.htmldirectly - ❌ Must implement client-side auth checks
- ❌ Can bypass authentication by manipulating JavaScript
After:
- ✅ Server checks authentication before rendering
- ✅ FastAPI
Depends()enforces auth at route level - ✅ Cannot bypass server-side checks
4. Better SEO (Future-Proof)
Before:
- ❌ Static HTML with no dynamic meta tags
- ❌ Same title/description for all pages
After:
- ✅ Can generate dynamic meta tags per page
- ✅ Can inject structured data for search engines
{% block head %}
<meta name="description" content="Admin dashboard for {{ user.company_name }}" />
<meta property="og:title" content="Dashboard - {{ user.company_name }}" />
{% endblock %}
5. Easier Maintenance
Scenario: Update header navigation
Before:
Edit header.html → Save → Test → Works ✓
But: Must reload page to see changes (AJAX loads old cached version)
After:
Edit header.html → Save → Refresh → Works ✓
Changes appear immediately (server-side include)
6. Better Developer Experience
| Task | Before | After |
|---|---|---|
| Create new page | Copy 150 lines, modify content | Extend base, write 40 lines |
| Change layout | Edit every HTML file | Edit base.html once |
| Add auth to page | Write JavaScript checks | Add Depends() to route |
| Pass user data | API call in Alpine.js | Available in template |
| Debug template | Browser + Network tab | Browser + FastAPI logs |
Trade-offs
1. Server Load ⚖️
Before:
- Serving static files (nginx-level, very fast)
- No server-side processing
After:
- FastAPI renders Jinja2 on each request
- Minimal overhead (~1-2ms per render)
Mitigation:
- Jinja2 is extremely fast (C-compiled)
- Can add template caching if needed
- Static assets still served by nginx
Verdict: Negligible impact for admin panel traffic
2. Learning Curve ⚖️
Before:
- Developers only need HTML + Alpine.js
After:
- Developers need Jinja2 syntax
- Must understand template inheritance
Mitigation:
- Jinja2 is very similar to Django/Flask templates
- Documentation provided
- Pattern is straightforward once learned
Verdict: Small one-time learning cost
3. Debugging Changes ⚖️
Before:
- View source in browser = actual file
- Easy to inspect what's loaded
After:
- View source = rendered output
- Must check template files in codebase
Mitigation:
- Better error messages from FastAPI
- Template path shown in errors
- Can enable Jinja2 debug mode
Verdict: Different, not harder
4. Cannot Edit in Browser DevTools ⚖️
Before:
- Can edit static HTML in browser, reload
After:
- Must edit template file, refresh server
Mitigation:
- FastAPI auto-reloads on file changes
- Hot reload works well in development
Verdict: Minimal impact, proper workflow
Code Examples
Example 1: Base Template
{# app/templates/admin/base.html #}
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Admin Panel{% endblock %} - Multi-Tenant Platform</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS -->
<link rel="stylesheet" href="{{ url_for('static', path='/admin/css/tailwind.output.css') }}" />
<!-- Alpine Cloak -->
<style>
[x-cloak] { display: none !important; }
</style>
{% block extra_head %}{% endblock %}
</head>
<body x-cloak>
<div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
<!-- Sidebar -->
{% include 'partials/sidebar.html' %}
<div class="flex flex-col flex-1 w-full">
<!-- Header -->
{% include 'partials/header.html' %}
<!-- Main Content -->
<main class="h-full overflow-y-auto">
<div class="container px-6 mx-auto grid">
{% block content %}{% endblock %}
</div>
</main>
</div>
</div>
<!-- Core Scripts -->
<script src="{{ url_for('static', path='/shared/js/icons.js') }}"></script>
<script src="{{ url_for('static', path='/admin/js/init-alpine.js') }}"></script>
<script src="{{ url_for('static', path='/shared/js/api-client.js') }}"></script>
<script src="{{ url_for('static', path='/shared/js/utils.js') }}"></script>
<!-- Alpine.js v3 -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>
Key Features:
{% block alpine_data %}- Allows child templates to specify Alpine component{{ url_for('static', path='...') }}- Proper static file URLs{% include %}- Server-side include (no AJAX needed){% block content %}- Child templates inject content here{% block extra_scripts %}- Page-specific JavaScript
Example 2: Child Template (Dashboard)
{# app/templates/admin/dashboard.html #}
{% extends "admin/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block alpine_data %}adminDashboard(){% endblock %}
{% block content %}
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Dashboard
</h2>
<button
@click="refresh()"
:disabled="loading"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white bg-purple-600 rounded-lg hover:bg-purple-700"
>
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
</div>
<!-- Stats Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full">
<span x-html="$icon('user-group', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Vendors
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.totalVendors">
0
</p>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='/admin/js/dashboard.js') }}"></script>
{% endblock %}
Key Features:
- Extends
base.htmlfor layout - Overrides specific blocks (
title,alpine_data,content,extra_scripts) - Uses Alpine.js directives (
x-data,x-show,x-text,x-html) - Uses icon system via
$icon()magic helper - Page-specific JavaScript loaded via
extra_scriptsblock
Example 3: Route Configuration (pages.py)
# app/api/v1/admin/pages.py
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user, get_db
from app.models import User
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/dashboard", response_class=HTMLResponse)
async def admin_dashboard_page(
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render admin dashboard page.
Requires admin authentication.
"""
# Optional: Pass initial data to avoid extra API call
# initial_stats = await get_dashboard_stats(db)
return templates.TemplateResponse(
"admin/dashboard.html",
{
"request": request,
"user": current_user,
# "initial_stats": initial_stats,
}
)
@router.get("/vendors", response_class=HTMLResponse)
async def admin_vendors_page(
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render vendors management page.
Requires admin authentication.
"""
return templates.TemplateResponse(
"admin/vendors.html",
{
"request": request,
"user": current_user,
}
)
@router.get("/users", response_class=HTMLResponse)
async def admin_users_page(
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render users management page.
Requires admin authentication.
"""
return templates.TemplateResponse(
"admin/users.html",
{
"request": request,
"user": current_user,
}
)
Example 4: Main App Configuration
# app/main.py
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.api.v1.admin import routes as admin_api_routes
from app.api.v1.admin import pages as admin_page_routes
app = FastAPI(title="Multi-Tenant Platform")
# Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# Configure Jinja2
templates = Jinja2Templates(directory="app/templates")
# Include API routes (JSON endpoints)
app.include_router(
admin_api_routes.router,
prefix="/api/v1/admin",
tags=["admin-api"]
)
# Include page routes (HTML rendering)
app.include_router(
admin_page_routes.router,
prefix="/admin",
tags=["admin-pages"]
)
@app.get("/")
async def root():
return {"message": "Multi-Tenant Platform API"}
Testing Strategy
Unit Tests
Test Template Rendering:
# tests/test_templates.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_dashboard_requires_authentication():
"""Dashboard should redirect to login if not authenticated."""
response = client.get("/admin/dashboard")
assert response.status_code == 401 # or 302 redirect
def test_dashboard_renders_for_authenticated_user(authenticated_client):
"""Dashboard should render for authenticated admin user."""
response = authenticated_client.get("/admin/dashboard")
assert response.status_code == 200
assert b"Dashboard" in response.content
assert b"<!DOCTYPE html>" in response.content
def test_dashboard_includes_user_data(authenticated_client, test_user):
"""Dashboard should include user information."""
response = authenticated_client.get("/admin/dashboard")
assert test_user.email.encode() in response.content
Test Alpine.js Integration:
def test_dashboard_includes_alpine_js(authenticated_client):
"""Dashboard should include Alpine.js script."""
response = authenticated_client.get("/admin/dashboard")
assert b"alpinejs" in response.content
assert b'x-data="adminDashboard()"' in response.content
Integration Tests
Test Full Page Flow:
# tests/integration/test_admin_pages.py
from playwright.sync_api import sync_playwright
def test_dashboard_loads_and_fetches_data():
"""Test dashboard loads, Alpine initializes, and fetches data."""
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
# Navigate to dashboard
page.goto("http://localhost:8000/admin/dashboard")
# Should redirect to login if not authenticated
assert page.url.endswith("/login")
# Login
page.fill('input[name="email"]', "admin@example.com")
page.fill('input[name="password"]', "password")
page.click('button[type="submit"]')
# Should redirect to dashboard
page.wait_for_url("**/admin/dashboard")
# Wait for Alpine to initialize
page.wait_for_selector('[x-data="adminDashboard()"]')
# Check stats cards load
page.wait_for_selector(".stat-card")
# Verify data is displayed
assert page.locator('text="Total Vendors"').is_visible()
browser.close()
Performance Tests
Measure Rendering Time:
import time
from fastapi.testclient import TestClient
def test_dashboard_render_performance(authenticated_client):
"""Dashboard should render in under 50ms."""
start = time.time()
response = authenticated_client.get("/admin/dashboard")
duration = time.time() - start
assert response.status_code == 200
assert duration < 0.05 # 50ms
Rollback Plan
If Issues Arise During Migration
Phase 1 Rollback (Setup Phase):
- No rollback needed - no breaking changes
- Simply don't use new templates yet
Phase 2 Rollback (Dashboard Migration):
- Keep old dashboard.html in place
- Remove new route from
pages.py - Revert any URL changes
- No data loss or downtime
Phase 3-4 Rollback:
- Restore old HTML files from backup
- Re-add
partial-loader.js - Update navigation links back to old URLs
- Remove new routes from
pages.py
Emergency Rollback Script:
#!/bin/bash
# rollback.sh
echo "Rolling back Jinja2 migration..."
# Restore old HTML files
git checkout main -- static/admin/dashboard.html
git checkout main -- static/admin/vendors.html
git checkout main -- static/admin/users.html
git checkout main -- static/shared/js/partial-loader.js
# Remove new page routes
git checkout main -- app/api/v1/admin/pages.py
# Restart server
echo "Restarting server..."
systemctl restart fastapi-app
echo "Rollback complete!"
Health Checks
Monitor After Deployment:
# app/health.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/health/templates")
async def check_templates():
"""Check if templates are rendering correctly."""
try:
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="app/templates")
# Try to get template
templates.get_template("admin/base.html")
return {"status": "healthy", "templates": "ok"}
except Exception as e:
return {"status": "unhealthy", "error": str(e)}
Conclusion
This migration from client-side static HTML to server-side Jinja2 templates provides:
Key Benefits:
- ✅ 38% reduction in code duplication
- ✅ 66% fewer HTTP requests on initial page load
- ✅ 50% faster time to first paint
- ✅ Better security with server-side authentication
- ✅ Cleaner URLs and better SEO
- ✅ Easier maintenance and development
Minimal Trade-offs:
- ⚠️ Slight increase in server load (negligible)
- ⚠️ Small learning curve for Jinja2 syntax
- ⚠️ Different debugging workflow
Next Steps:
- Review and approve this architecture document
- Schedule migration phases
- Begin Phase 1 (infrastructure setup)
- Migrate dashboard as proof of concept
- Roll out to remaining pages
- Monitor and optimize
Timeline: 1-2 days for complete migration Risk Level: Low (incremental, reversible changes)