Development Guide¶
Daily development workflow for Vibetuner projects.
Development Environment¶
Vibetuner supports two development modes:
Docker Development (Recommended)¶
Run everything in containers with hot reload:
This starts:
- Database (MongoDB or SQL, if configured)
- Redis (if background jobs enabled)
- FastAPI application with auto-reload
- Frontend asset compilation with watch mode
Changes to Python code, templates, and assets automatically reload.
Local Development¶
Run services locally without Docker:
just local-all # Runs server + assets with auto-port (recommended)
just local-all-with-worker # Includes background worker (requires Redis)
A database (MongoDB or SQL) is required if using database features. Redis is only required if background jobs are enabled.
Justfile Commands Reference¶
All project management tasks use just (command runner). Run just without arguments to see all
available commands.
Development¶
just local-all # Local dev: server + assets with auto-port (recommended)
just local-all-with-worker # Local dev with background worker (requires Redis)
just dev # Docker development with hot reload
just local-dev PORT=8000 # Local server only (run bun dev separately)
just worker-dev # Background worker only
Dependencies¶
just install-deps # Install from lockfiles
just update-repo-deps # Update root scaffolding dependencies
just update-and-commit-repo-deps # Update deps and commit changes
uv add package-name # Add Python package
bun add package-name # Add JavaScript package
Code Formatting¶
just format # Format ALL code (Python, Jinja, TOML, YAML)
just format-py # Format Python with ruff
just format-jinja # Format Jinja templates with djlint
just format-toml # Format TOML files with taplo
just format-yaml # Format YAML files with dprint
IMPORTANT: Always run ruff format . or just format-py after Python changes.
Code Linting¶
just lint # Lint ALL code
just lint-py # Lint Python with ruff
just lint-jinja # Lint Jinja templates with djlint
just lint-md # Lint markdown files
just lint-toml # Lint TOML files with taplo
just lint-yaml # Lint YAML files with dprint
just type-check # Type check Python with ty
Localization (i18n)¶
just i18n # Full workflow: extract, update, compile
just extract-translations # Extract translatable strings
just update-locale-files # Update existing .po files
just compile-locales # Compile .po to .mo files
just new-locale LANG # Create new language (e.g., just new-locale es)
just dump-untranslated DIR # Export untranslated strings
CI/CD & Deployment¶
just build-dev # Build development Docker image
just test-build-prod # Test production build locally
just build-prod # Build production image (requires clean tagged commit)
just release # Build and release production image
just deploy-latest HOST # Deploy to remote host
Scaffolding Updates¶
Parallel Development¶
just feature-new NAME # Create new feature worktree
just feature-list # List all feature worktrees
just feature-done [NAME] # Remove worktree + delete merged branch
just feature-drop [NAME] # Force remove worktree + delete branch
just feature-rebase # Sync current branch with origin/main
Parallel Development with Worktrees¶
Work on multiple features simultaneously using git worktrees. Each worktree is an isolated copy of the repository with its own branch, allowing true parallel development.
When to Use Worktrees¶
| Approach | Use When |
|---|---|
Worktrees (feature-new) |
Working on multiple features, need clean isolation, instant context switching |
Simple branch (git checkout) |
Single feature at a time, quick fixes, prefer simpler workflow |
Creating a Feature Worktree¶
just feature-new feat/user-dashboard
# Creates worktrees/a1b2c3d4/ with branch feat/user-dashboard
# Symlinks .env for shared configuration
# Runs mise trust if available
cd worktrees/a1b2c3d4
# Work on your feature...
Listing Feature Worktrees¶
Completing a Feature¶
After your PR is merged, clean up the worktree and branch:
# Auto-detect from current directory (run from within the worktree)
just feature-done
# By branch name
just feature-done feat/user-dashboard
# By directory path
just feature-done ./worktrees/a1b2c3d4
If you're inside the worktree when running feature-done, you'll be reminded to cd back to
the main repository since the directory will be deleted.
Abandoning Unmerged Work¶
Use feature-drop to force-remove a worktree even if the branch has unmerged changes:
Keeping Features Up to Date¶
Rebase your feature branch on latest main:
Common Tasks¶
Adding New Routes¶
Create a new file in src/app/frontend/routes/:
# src/app/frontend/routes/blog.py
from fastapi import APIRouter
router = APIRouter(prefix="/blog", tags=["blog"])
@router.get("/")
async def list_posts():
return {"posts": []}
Register in src/app/frontend/__init__.py:
Adding Database Models¶
Create models in src/app/models/. The approach depends on your database choice:
MongoDB (Beanie ODM)¶
# src/app/models/post.py
from beanie import Document
from pydantic import Field
class Post(Document):
title: str
content: str
published: bool = Field(default=False)
class Settings:
name = "posts"
SQL (SQLModel)¶
# src/app/models/post.py
from sqlmodel import SQLModel, Field
class Post(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
title: str
content: str
published: bool = Field(default=False)
For SQL databases, create tables with: vibetuner db create-schema
Register models in src/app/models/__init__.py:
Creating Templates¶
Add templates in templates/:
<!-- templates/blog/list.html.jinja -->
{% extends "base/skeleton.html.jinja" %}
{% block content %}
<div class="container mx-auto">
<h1 class="text-3xl font-bold">Blog Posts</h1>
<div class="grid gap-4">
{% for post in posts %}
<article class="card">
<h2>{{ post.title }}</h2>
<div>{{ post.content }}</div>
</article>
{% endfor %}
</div>
</div>
{% endblock content %}
Adding Custom Template Filters¶
Create custom Jinja2 filters in src/app/frontend/templates.py:
# src/app/frontend/templates.py
from vibetuner.frontend.templates import register_filter
@register_filter()
def uppercase(value):
"""Convert value to uppercase"""
return str(value).upper()
@register_filter("money")
def format_money(value):
"""Format value as USD currency"""
try:
return f"${float(value):,.2f}"
except (ValueError, TypeError):
return str(value)
Use in templates:
The @register_filter() decorator automatically registers filters with the Jinja
environment. If no name is provided, the function name becomes the filter name.
Adding Background Jobs¶
If you enabled background jobs, create tasks in src/app/tasks/:
# src/app/tasks/emails.py
from vibetuner.tasks.worker import worker
from vibetuner.models import UserModel
from vibetuner.services.email import send_email
@worker.task()
async def send_welcome_email(user_id: str):
user = await UserModel.get(user_id)
if user:
await send_email(
to_email=user.email,
subject="Welcome!",
html_content="<h1>Welcome!</h1>"
)
return {"status": "sent"}
Register tasks in src/app/tasks/__init__.py:
Queue jobs from your routes:
from app.tasks.emails import send_welcome_email
@router.post("/signup")
async def signup(email: str):
# Create user
user = await create_user(email)
# Queue background task
task = await send_welcome_email.enqueue(str(user.id))
return {"message": "Welcome email queued", "task_id": task.id}
Styling with Tailwind¶
Vibetuner uses Tailwind CSS + DaisyUI. Edit assets/config.css for custom styles:
/* assets/config.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
.btn-custom {
@apply btn btn-primary rounded-full;
}
The build process automatically compiles to assets/statics/css/bundle.css.
Working with HTMX¶
Vibetuner uses HTMX for interactive features without JavaScript:
<!-- Load more posts -->
<button hx-get="/blog?page=2"
hx-target="#posts"
hx-swap="beforeend"
class="btn btn-primary">Load More</button>
<div id="posts">
<!-- Posts will be appended here -->
</div>
Server endpoint:
@router.get("/blog")
async def list_posts(page: int = 1):
posts = await Post.find().skip((page - 1) * 10).limit(10).to_list()
return templates.TemplateResponse("blog/posts.html.jinja", {
"posts": posts
})
Internationalization¶
Extracting Translations¶
After adding translatable strings:
This scans your code and templates for {% trans %} blocks and gettext() calls.
Adding New Languages¶
Updating Translations¶
Edit .po files in translations/:
Compile translations:
Using in Templates¶
Using in Python¶
Template Context Variables for i18n¶
The following language-related variables are available in templates:
| Variable | Type | Description |
|---|---|---|
default_language |
str |
Default language code (e.g., "en") |
supported_languages |
set[str] |
Set of supported language codes |
locale_names |
dict[str, str] |
Language codes to native display names |
Using locale_names for Language Selectors¶
The locale_names dict maps language codes to their native display names, sorted alphabetically:
<select name="language">
{% for code, name in locale_names.items() %}
<option value="{{ code }}"
{% if code == current_language %}selected{% endif %}>
{{ name }}
</option>
{% endfor %}
</select>
Example output: {"ca": "Català", "en": "English", "es": "Español"}
SEO-Friendly Language URLs¶
Vibetuner supports path-prefix language routing for SEO-friendly URLs (e.g., /ca/privacy,
/es/about).
How It Works¶
The LangPrefixMiddleware handles path-prefix language routing:
| URL | Behavior |
|---|---|
/ca/dashboard |
Strips prefix → /dashboard, sets lang=ca |
/dashboard (anonymous) |
Serves directly using detected/default language |
/dashboard (authenticated) |
301 redirects to /{user_lang}/dashboard |
/xx/dashboard (invalid) |
Returns 404 Not Found |
/ca |
Redirects to /ca/ |
/static/... |
Bypassed, serves static file directly |
Language Detection Priority¶
Languages are detected in this order (first match wins):
- Query parameter (
?l=es) - URL path prefix (
/ca/...) - User preference (from session, for authenticated users)
- Cookie (
languagecookie) - Accept-Language header (browser preference)
- Default language
Redirect Behavior¶
Localized routes follow these rules:
- Anonymous users: Served at unprefixed URL using detected/default language
- Authenticated users: 301 permanent redirect to
/{lang}/path
This approach optimizes for SEO: search engines crawl the unprefixed URL (which serves the
default language) and discover language variants via hreflang tags, while authenticated
users get a personalized, bookmarkable URL.
Using LocalizedRouter (Recommended)¶
Use LocalizedRouter to control localization at the router level. All routes automatically
handle language prefix redirects:
from fastapi import Request
from vibetuner.frontend import LocalizedRouter
from vibetuner.frontend.templates import render_template
# All routes in this router are localized
legal_router = LocalizedRouter(prefix="/legal", localized=True)
@legal_router.get("/privacy")
async def privacy(request: Request):
return render_template("legal/privacy.html.jinja", request)
# Anonymous: served at /legal/privacy
# Authenticated: redirected to /{lang}/legal/privacy
# All routes in this router are non-localized (API endpoints)
api_router = LocalizedRouter(prefix="/api", localized=False)
@api_router.get("/users")
async def users():
return {"users": []} # Always at /api/users, no redirects
Using @localized Decorator¶
For individual routes on a regular APIRouter, use the @localized decorator:
from fastapi import APIRouter, Request
from vibetuner.frontend import localized
from vibetuner.frontend.templates import render_template
router = APIRouter()
@router.get("/privacy")
@localized
async def privacy(request: Request):
return render_template("privacy.html.jinja", request)
Generating Language URLs in Templates¶
Two helpers are available for generating language-prefixed URLs:
lang_url_for: Uses the current request's language:
<a href="{{ lang_url_for(request, 'privacy') }}">Privacy Policy</a>
<!-- Output: /ca/privacy (if current language is Catalan) -->
url_for_language: Specify a target language explicitly (for language switchers):
Adding hreflang Tags for SEO¶
Use hreflang_tags to generate proper hreflang link tags in your page head:
<!-- In your base template <head> -->
{{ hreflang_tags(request, supported_languages, default_language)|safe }}
This outputs:
<link rel="alternate" hreflang="ca" href="https://example.com/ca/privacy" />
<link rel="alternate" hreflang="en" href="https://example.com/en/privacy" />
<link rel="alternate" hreflang="es" href="https://example.com/es/privacy" />
<link rel="alternate" hreflang="x-default" href="https://example.com/privacy" />
Note: x-default points to the unprefixed URL, which serves the default/detected language.
Complete Example¶
Route definition:
# src/app/frontend/routes/legal.py
from fastapi import Request
from vibetuner.frontend import LocalizedRouter
from vibetuner.frontend.templates import render_template
router = LocalizedRouter(tags=["legal"], localized=True)
@router.get("/privacy")
async def privacy(request: Request):
return render_template("legal/privacy.html.jinja", request)
@router.get("/terms")
async def terms(request: Request):
return render_template("legal/terms.html.jinja", request)
Template with hreflang:
<!-- templates/legal/privacy.html.jinja -->
{% extends "base/skeleton.html.jinja" %}
{% block head %}
{{ hreflang_tags(request, supported_languages, default_language)|safe }}
{% endblock head %}
{% block content %}
<h1>{% trans %}Privacy Policy{% endtrans %}</h1>
<!-- Content -->
{% endblock content %}
Language switcher using url_for_language:
<!-- templates/partials/language_switcher.html.jinja -->
<div class="dropdown">
{% for code, name in locale_names.items() %}
<a href="{{ url_for_language(request, code, request.scope.endpoint.__name__) }}"
{% if code == language %}class="active"{% endif %}>
{{ name }}
</a>
{% endfor %}
</div>
Debugging¶
View Logs¶
Access Database¶
# MongoDB
docker compose exec mongodb mongosh
# PostgreSQL
docker compose exec postgres psql -U postgres
Interactive Shell¶
Testing¶
Run Tests¶
Test Coverage¶
Integration Tests¶
Integration tests should use a real database (not mocks):
import pytest
from app.models import Post
@pytest.mark.asyncio
async def test_create_post():
post = Post(title="Test", content="Content")
await post.insert() # MongoDB with Beanie
found = await Post.get(post.id)
assert found.title == "Test"
Code Quality¶
Format Code¶
Runs:
ruff formatfor Python
Check Code¶
Runs:
ruff checkfor Python- Type checking
- Template validation
Environment Configuration¶
Development Settings¶
Copy .env.local to .env:
Edit as needed:
# .env
# MongoDB
MONGODB_URL=mongodb://localhost:27017/myapp
# Or SQL database (PostgreSQL, MySQL, MariaDB, SQLite)
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/myapp
# DATABASE_URL=sqlite+aiosqlite:///./data.db
SECRET_KEY=your-secret-key-here
DEBUG=true
# OAuth (optional)
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
Production Settings¶
Use environment variables or .env file in production:
# MongoDB
MONGODB_URL=mongodb://prod-server:27017/myapp
# Or SQL database
DATABASE_URL=postgresql+asyncpg://user:pass@prod-server/myapp
SECRET_KEY=very-secret-key
DEBUG=false
Keeping the Scaffold Up to Date¶
When new versions of the template ship, update your project using either:
vibetuner scaffold update– works from anywhere; replays Copier with your saved answers.just update-scaffolding– runs inside the generated project and wrapscopier updateplus dependency sync.
Both commands modify tracked files, so commit or stash your work beforehand and review the changes afterward. See the Scaffolding Reference for a deeper walkthrough.
Dependency Management¶
Add Python Package¶
Add JavaScript Package¶
Sync All Dependencies¶
Syncs both Python and JavaScript dependencies.
Next Steps¶
- Authentication - Set up OAuth providers
- Deployment - Deploy to production
- Architecture - Understand the system design