Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
#### API & Frontend Options
- API frameworks: Django REST Framework, Strawberry GraphQL, both, or none
- Frontend: HTMX + Tailwind CSS, Next.js, or headless
- Frontend bundling for HTMX+Tailwind: Vite (production-ready) or CDN (simple)
- Authentication: django-allauth, JWT, or both

#### Background Task Processing
Expand Down
9 changes: 9 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ frontend:
{%- elif project_type == 'internal-tool' -%}htmx-tailwind
{%- else -%}none{%- endif -%}

frontend_bundling:
type: str
help: Frontend asset bundling
when: "{{ frontend == 'htmx-tailwind' }}"
choices:
Vite (Production-ready, bundled assets): vite
CDN (Simple, no build step): cdn
default: "vite"

# Async & Tasks
background_tasks:
type: str
Expand Down
59 changes: 59 additions & 0 deletions docs/features/frontend-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,65 @@ Modern, minimal-JavaScript approach with server-rendered HTML.
- **Tailwind CSS** for styling
- **Alpine.js** for lightweight JavaScript

#### Asset Bundling Options

When you select HTMX + Tailwind, you can choose how assets are delivered:

| Option | Best For | Build Step | External Dependencies |
|--------|----------|------------|----------------------|
| **Vite** (default) | Production apps | Yes | None |
| **CDN** | Prototypes, MVPs | No | Yes (3 CDNs) |

**Vite (Recommended for Production)**

- Bundles Tailwind CSS, HTMX, and Alpine.js locally
- No external CDN dependencies in production
- Proper CSS purging for smaller bundles
- Hot Module Replacement (HMR) in development
- Uses `django-vite` for seamless Django integration

**Development with Docker (Recommended):**
```bash
docker-compose up # Starts Django + Vite dev server together
```

**Development without Docker:**
```bash
# Terminal 1: Start Vite dev server
cd frontend
npm install
npm run dev

# Terminal 2: Start Django
python manage.py runserver
```

**Building for Production:**
```bash
# Build optimized assets locally
cd frontend
npm run build # Outputs to static/dist/

# Or let Docker handle it
docker build -t myapp . # Multi-stage build includes npm run build
```

**Troubleshooting:**

| Issue | Solution |
|-------|----------|
| Vite HMR not working in Docker | Ensure `VITE_DEV_SERVER_HOST=vite` is set in the Django web service environment |
| Styles not updating | Check Tailwind is scanning `../templates/**/*.html` |
| Assets 404 in production | Run `python manage.py collectstatic` after building |
| `django_vite` template errors | Ensure `DEBUG=False` uses built manifest, not dev server |

**CDN (Simple, No Build Step)**

- Assets loaded from external CDNs (tailwindcss, unpkg, jsdelivr)
- Zero configuration needed
- Good for quick prototypes
- Not recommended for production (external dependencies)

### Next.js

Full-featured React framework for building modern web applications.
Expand Down
8 changes: 8 additions & 0 deletions template/.gitignore.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ db.sqlite3-journal
/media
/staticfiles
/static_root
{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%}
/static/dist
{% endif -%}

# Environment
.env
Expand All @@ -42,6 +45,11 @@ db.sqlite3-journal
.venv/
poetry.lock
{% endif -%}
{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%}

# Node
node_modules/
{% endif -%}

# IDEs
.vscode/
Expand Down
29 changes: 29 additions & 0 deletions template/Dockerfile.jinja
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
# syntax=docker/dockerfile:1
{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%}
# Build frontend assets
FROM node:20-slim AS frontend-builder

WORKDIR /app/frontend

# Copy frontend files
COPY frontend/package*.json ./

# Install dependencies
RUN npm ci

# Copy rest of frontend
COPY frontend/ ./

# Build assets (outputs to ../static/dist per vite.config.js)
RUN npm run build

{% endif -%}
FROM python:{{ python_version }}-slim as base

# Set environment variables
Expand Down Expand Up @@ -42,6 +61,11 @@ RUN uv sync --no-dev
# Copy rest of application
COPY . .

{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%}
# Copy built frontend assets
COPY --from=frontend-builder /app/static/dist ./static/dist
{% endif -%}

{% else -%}
# Install poetry
RUN pip install poetry==1.7.0
Expand All @@ -61,6 +85,11 @@ RUN poetry config virtualenvs.create false \

# Copy rest of application
COPY . .

{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%}
# Copy built frontend assets
COPY --from=frontend-builder /app/static/dist ./static/dist
{% endif -%}
{% endif -%}

# Change ownership
Expand Down
16 changes: 16 additions & 0 deletions template/config/settings/base.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ DJANGO_APPS = [
]

THIRD_PARTY_APPS = [
{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%}
"django_vite",
{% endif -%}
{% if api_style in ['drf', 'both'] -%}
"rest_framework",
"drf_spectacular",
Expand Down Expand Up @@ -242,6 +245,19 @@ STORAGES = {
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%}
# Django Vite
DJANGO_VITE = {
"default": {
"dev_mode": DEBUG,
"manifest_path": BASE_DIR / "static" / "dist" / "manifest.json",
"static_url_prefix": "dist/",
"dev_server_host": env("VITE_DEV_SERVER_HOST", default="localhost"),
"dev_server_port": 5173,
}
}
{% endif -%}

{% if media_storage == 'aws-s3' -%}
# AWS S3 Storage
AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID", default="")
Expand Down
15 changes: 15 additions & 0 deletions template/docker-compose.yml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ services:
- EMAIL_PORT=1025
{% if dependency_manager == 'uv' %}
- UV_NO_CACHE=1
{% endif %}
{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}
- VITE_DEV_SERVER_HOST=vite
{% endif %}
env_file:
- .env
Expand Down Expand Up @@ -65,6 +68,18 @@ services:
ports:
- "1025:1025"
- "8025:8025"
{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}

vite:
image: node:20-slim
working_dir: /app/frontend
volumes:
- ./frontend:/app/frontend
- ./static:/app/static
ports:
- "5173:5173"
command: sh -c "npm ci && npm run dev"
{% endif %}
{% if background_tasks in ['temporal', 'both'] %}

temporal:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "{{ project_slug }}-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"htmx.org": "^1.9.10",
"alpinejs": "^3.13.3"
},
"devDependencies": {
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"vite": "^5.0.10"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Import styles
import "./styles.css";

// Import HTMX
import htmx from "htmx.org";
window.htmx = htmx;

// Import Alpine.js
import Alpine from "alpinejs";
window.Alpine = Alpine;
Alpine.start();
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"../templates/**/*.html",
"./src/**/*.js",
],
theme: {
extend: {},
},
plugins: [],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import { resolve } from "path";

export default defineConfig({
base: "/static/",
build: {
manifest: "manifest.json",
outDir: resolve(__dirname, "../static/dist"),
rollupOptions: {
input: {
main: resolve(__dirname, "src/main.js"),
},
},
},
server: {
host: "0.0.0.0",
port: 5173,
origin: "http://localhost:5173",
},
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
<!DOCTYPE html>
{% if frontend_bundling == 'vite' %}{% raw %}{% load django_vite %}{% endraw %}
{% endif %}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% raw %}{% block title %}{% endraw %}{{ project_name }}{% raw %}{% endblock %}{% endraw %}</title>

{% if frontend_bundling == 'vite' %}
{% raw %}{% vite_hmr_client %}{% endraw %}
{% raw %}{% vite_asset 'src/main.js' %}{% endraw %}
{% else %}
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>

Expand All @@ -13,6 +18,7 @@

<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
{% endif %}

{% raw %}{% block extra_head %}{% endblock %}{% endraw %}
</head>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ django-opensearch-dsl = "^0.6.0"
{% if use_i18n -%}
django-parler = "^2.3.0"
{% endif -%}
{% if frontend_bundling == 'vite' -%}
django-vite = "^3.0.0"
{% endif -%}
django-extensions = "^3.2.0"
python-dotenv = "^1.0.0"
django-alive = "^1.3.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ dependencies = [
{% endif -%}
{% if use_i18n -%}
"django-parler>=2.3.0",
{% endif -%}
{% if frontend_bundling == 'vite' -%}
"django-vite>=3.0.0",
{% endif -%}
"django-extensions>=3.2.0",
"python-dotenv>=1.0.0",
Expand Down
1 change: 1 addition & 0 deletions tests/test_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ def test_all_features_enabled_generates_successfully(generate):
dependency_manager="uv",
api_style="both",
frontend="htmx-tailwind",
frontend_bundling="vite", # Explicitly test Vite (the default)
background_tasks="celery",
use_channels=True,
auth_backend="both",
Expand Down
34 changes: 33 additions & 1 deletion tests/test_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,16 +204,48 @@ def test_no_api_excludes_frameworks(generate):


def test_htmx_frontend_templates_generated(generate):
"""Test that HTMX frontend generates templates."""
"""Test that HTMX frontend generates templates with Vite (default)."""
project = generate(frontend="htmx-tailwind")

templates_dir = project / "templates"
assert (templates_dir / "base.html").exists()

base_html = (templates_dir / "base.html").read_text()
assert "{% block" in base_html
# Vite is the default, so check for vite tags
assert "django_vite" in base_html
assert "vite_asset" in base_html

# Vite frontend files should exist
frontend_dir = project / "frontend"
assert (frontend_dir / "package.json").exists()
assert (frontend_dir / "vite.config.js").exists()
assert (frontend_dir / "tailwind.config.js").exists()

Comment on lines 206 to +224
Copy link

Copilot AI Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding assertions to verify that django_vite is added to INSTALLED_APPS and that DJANGO_VITE settings are present in the generated settings file, similar to how test_drf_api_generated checks for DRF configuration. This would provide more comprehensive coverage.

Example additions:

# Check Django settings
settings = project / "config/settings/base.py"
settings_content = settings.read_text()
assert "django_vite" in settings_content
assert "DJANGO_VITE" in settings_content

Copilot uses AI. Check for mistakes.
# Check Django settings include django_vite
settings = project / "config" / "settings" / "base.py"
settings_content = settings.read_text()
assert "django_vite" in settings_content
assert "DJANGO_VITE" in settings_content


def test_htmx_frontend_cdn_mode(generate):
"""Test that HTMX frontend with CDN mode uses CDN links."""
project = generate(frontend="htmx-tailwind", frontend_bundling="cdn")
Copy link

Copilot AI Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test test_all_features_enabled_generates_successfully in tests/test_features.py (line 390) uses frontend='htmx-tailwind' but doesn't specify frontend_bundling. Since Vite is the default, consider explicitly testing with frontend_bundling='vite' in that test or adding a comment explaining the default behavior is being tested.

Copilot uses AI. Check for mistakes.

templates_dir = project / "templates"
base_html = (templates_dir / "base.html").read_text()
assert "{% block" in base_html
assert "tailwindcss" in base_html
assert "htmx" in base_html
# Should not have vite tags
assert "django_vite" not in base_html
Comment on lines +232 to +242
Copy link

Copilot AI Nov 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider adding a test that verifies CDN mode doesn't create frontend files (package.json, vite.config.js, etc.) to ensure the conditional file generation works correctly in both directions.

Copilot uses AI. Check for mistakes.

# CDN mode should NOT create frontend build files
frontend_dir = project / "frontend"
assert not (frontend_dir / "package.json").exists()
assert not (frontend_dir / "vite.config.js").exists()
assert not (frontend_dir / "tailwind.config.js").exists()


def test_nextjs_frontend_generated(generate):
Expand Down
Loading