Skip to content

Starter Project Anatomy

This document describes the structure and components of the CB Radio starter project, which serves as a template for building FastAPI + FastHTML web applications with DuckDB.

Overview

The starter project provides a complete, production-ready foundation with: - JWT authentication (admin/admin123 default user) - FastAPI backend with modular routing - FastHTML frontend with Jinja2 templates - DuckDB database - TailwindCSS + HTMX for UI - Systemd service files for deployment

Directory Structure

cbradio/
├── .env                    # Environment configuration
├── requirements.txt        # Python dependencies
├── db/
│   └── cbradio.db         # DuckDB database file
├── docs/
│   └── STARTER-PROJECT.md # This file
├── scripts/
│   ├── config.py          # Shared configuration module
│   ├── create_schema.py   # Database schema + seed data
│   └── services/          # Systemd service files
│       ├── ruralamfm-api.service
│       └── ruralamfm-app.service
└── src/
    ├── __init__.py
    ├── api/               # FastAPI Backend
    │   ├── __init__.py
    │   ├── main.py        # FastAPI app entry point
    │   ├── auth.py        # JWT authentication utilities
    │   ├── config.py      # API-specific settings (Pydantic)
    │   ├── database.py    # DuckDB connection utilities
    │   ├── models/        # Pydantic data models
    │   │   ├── __init__.py
    │   │   └── user.py    # User, Token, TokenData models
    │   └── routes/        # API route handlers
    │       ├── __init__.py
    │       └── auth.py    # /api/auth/* endpoints
    └── app/               # FastHTML Frontend
        ├── __init__.py
        ├── main.py        # FastHTML app entry point
        ├── static/
        │   ├── css/
        │   │   └── styles.css
        │   ├── images/
        │   └── js/
        └── templates/     # Jinja2 templates
            ├── layout.html    # Base template with nav/sidebar
            ├── login.html     # Login page
            └── dashboard.html # Dashboard page

Configuration

Environment Variables (.env)

# Security
SECRET_KEY=dev-secret-key-change-in-production
API_KEYS=test-api-key-for-development

# Database
DB_PATH=db/cbradio.db

# Server Ports
API_PORT=32331
FRONTEND_PORT=32330
HOST=0.0.0.0

# URLs (for production)
FRONTEND_URL=https://ruralamfm.nominate.ai
BACKEND_API_URL=https://ruralamfm.nominate.ai/api
WS_BASE_URL=wss://ruralamfm.nominate.ai

# Branding
APP_NAME=Rural AM/FM
APP_NAME_SHORT=RAMFM

# Theme Colors
THEME_PRIMARY_COLOR=#0e173e
THEME_SECONDARY_COLOR=#f1c613
THEME_ACCENT_COLOR=#f1c613
THEME_TEXT_COLOR=#374151
THEME_SIDEBAR_COLOR=#f8fafc

scripts/config.py

Shared configuration module loaded by both API and frontend: - Loads .env via python-dotenv - Exports config dict with app settings - Exports DB_PATH for database location - Resolves relative paths from project root

src/api/config.py

Pydantic Settings class for type-safe API configuration: - Settings class with defaults - get_settings() cached function - Session timeout settings

Backend (src/api/)

main.py - FastAPI Application

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI(title="CB Radio API", version="0.1.0")

# CORS middleware for frontend
app.add_middleware(CORSMiddleware, ...)

# Include routers
app.include_router(auth.router, prefix="/api")

# Endpoints
GET  /api          # API info
GET  /api/health   # Health check
GET  /api/protected # Example protected route

auth.py - Authentication

JWT-based authentication with: - hash_password() / verify_password() - SHA256 hashing - get_user(username) - Fetch user from DB - authenticate_user(username, password) - Validate credentials - create_access_token(data) - Generate JWT - get_current_user() - FastAPI dependency - get_current_active_user() - Require active user

database.py - DuckDB Utilities

@contextmanager
def get_connection():
    """Context manager for DB connections."""

def execute_query(query, params) -> list[dict]:
    """Execute query, return list of dicts."""

def execute_and_fetch_one(query, params) -> dict | None:
    """Execute query, return first row."""

def generate_id() -> str:
    """Generate UUID for new records."""

models/user.py - User Models

class UserBase(BaseModel):      # username, email, first_name, last_name, role
class UserCreate(UserBase):     # + password
class UserUpdate(BaseModel):    # All optional fields
class User(UserBase):           # + id, is_active, timestamps
class UserInDB(User):           # + password_hash
class Token(BaseModel):         # access_token, token_type
class TokenData(BaseModel):     # id, username, role (JWT payload)

routes/auth.py - Auth Endpoints

POST /api/auth/token    # Login, returns JWT
GET  /api/auth/me       # Get current user

Frontend (src/app/)

main.py - FastHTML Application

from fasthtml import FastHTML
from starlette.middleware.sessions import SessionMiddleware

app = FastHTML(debug=True)
app.add_middleware(SessionMiddleware, ...)
app.mount("/static", StaticFiles(...))

templates = Jinja2Templates(directory="templates")

# Routes
GET/POST /login     # Login page and handler
GET      /logout    # Clear session, redirect
GET      /dashboard # Protected dashboard
GET      /          # Redirect to login or dashboard

API Helper Function

async def api_request(method, endpoint, token=None, data=None, params=None):
    """Make authenticated API request to backend."""
    # Returns JSON response or {"error": "message"}
    # Handles SESSION_EXPIRED for auto-logout

Templates

layout.html - Base template with: - TailwindCSS (CDN) - Lucide Icons - HTMX - Navigation bar - Sidebar (when logged in) - Session timeout modal - Theme CSS variables

login.html - Login form extending layout

dashboard.html - Welcome dashboard with: - User greeting - Status cards - Tech stack display

Database Schema

user table

CREATE TABLE user (
    id VARCHAR PRIMARY KEY,
    username VARCHAR UNIQUE NOT NULL,
    email VARCHAR,
    password_hash VARCHAR NOT NULL,
    first_name VARCHAR,
    last_name VARCHAR,
    role VARCHAR DEFAULT 'admin',
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

Default User

  • Username: admin
  • Password: admin123
  • Role: admin

Created automatically by scripts/create_schema.py.

Deployment

Systemd Services

ruralamfm-api.service - Backend API

ExecStart=/path/to/python -m uvicorn src.api.main:app --host 0.0.0.0 --port 32331

ruralamfm-app.service - Frontend App

ExecStart=/path/to/python -m uvicorn src.app.main:app --host 0.0.0.0 --port 32330

Service Commands

# Install services
sudo ln -sf /path/to/services/*.service /etc/systemd/system/
sudo systemctl daemon-reload

# Enable on boot
sudo systemctl enable ruralamfm-api ruralamfm-app

# Start/Stop/Restart
sudo systemctl start ruralamfm-api ruralamfm-app
sudo systemctl stop ruralamfm-api ruralamfm-app
sudo systemctl restart ruralamfm-api ruralamfm-app

# View logs
sudo journalctl -u ruralamfm-api -f
sudo journalctl -u ruralamfm-app -f

Nginx Configuration

Nginx routes requests to the appropriate service: - /api/* and /ws → port 32331 (backend) - / → port 32330 (frontend)

Adding New Features

New API Endpoint

  1. Create model in src/api/models/:

    class ItemBase(BaseModel):
        name: str
    class Item(ItemBase):
        id: str
    

  2. Create route in src/api/routes/:

    router = APIRouter(prefix="/items", tags=["items"])
    
    @router.get("/", response_model=list[Item])
    async def list_items():
        return execute_query("SELECT * FROM item")
    

  3. Register in src/api/main.py:

    from src.api.routes import items
    app.include_router(items.router, prefix="/api")
    

New Frontend Page

  1. Create template in src/app/templates/:

    {% extends "layout.html" %}
    {% block content %}
    <h1>My Page</h1>
    {% endblock %}
    

  2. Add route in src/app/main.py:

    @app.route("/mypage")
    async def mypage(request):
        token = get_token(request)
        if not token:
            return RedirectResponse(url="/login")
        return templates.TemplateResponse("mypage.html", {...})
    

  3. Add to sidebar in layout.html

New Database Table

  1. Add to scripts/create_schema.py:

    SCHEMA_SQL = """
    ...
    CREATE TABLE IF NOT EXISTS item (
        id VARCHAR PRIMARY KEY,
        name VARCHAR NOT NULL,
        created_at TIMESTAMP
    );
    """
    

  2. Run schema creation:

    python scripts/create_schema.py
    

Tech Stack Reference

Component Technology Version
Language Python 3.12.9
Backend FastAPI latest
Frontend FastHTML latest
Database DuckDB latest
Templates Jinja2 latest
CSS TailwindCSS 2.2.19 (CDN)
Icons Lucide latest (CDN)
Interactivity HTMX 1.9.2 (CDN)
Auth PyJWT latest
HTTP Client httpx latest
Validation Pydantic latest

Python Environment

# Activate virtualenv
source ~/.pyenv/versions/nominates/bin/activate

# Install dependencies
pip install -r requirements.txt

# Initialize database
python scripts/create_schema.py

# Run locally (development)
python -m uvicorn src.api.main:app --port 8000 --reload  # API
python -m uvicorn src.app.main:app --port 8080 --reload  # Frontend