Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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 for the web service |
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 troubleshooting entry is slightly misleading. The VITE_DEV_SERVER_HOST environment variable should be set for the Django web service (as shown in docker-compose.yml.jinja line 31), not the Vite service. Consider clarifying this by stating 'Ensure VITE_DEV_SERVER_HOST=vite is set as an environment variable in the web service configuration'.

Suggested change
| Vite HMR not working in Docker | Ensure `VITE_DEV_SERVER_HOST=vite` is set for the web service |
| Vite HMR not working in Docker | Ensure `VITE_DEV_SERVER_HOST=vite` is set as an environment variable in the web service configuration |

Copilot uses AI. Check for mistakes.
| 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/ ./

# Create output directory and build assets
RUN mkdir -p /app/static && npm run build
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 frontend builder creates /app/static but the COPY command on lines 66 and 91 copies from /app/static/dist to ./static/dist. The npm run build already outputs to ../static/dist per vite.config.js (line 8), so creating /app/static here is redundant. Consider removing mkdir -p /app/static && since Vite will create the necessary directories.

Suggested change
RUN mkdir -p /app/static && npm run build
RUN npm run build

Copilot uses AI. Check for mistakes.

{% 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
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