Skip to content

Development Guide

Daily development workflow for Vibetuner projects.

Package name convention

In all examples, app refers to your project's Python package (the directory under src/). The actual name depends on your project slug (e.g., src/myproject/ for a project named "myproject").

Development Environment

Vibetuner supports two development modes:

Run everything in containers with hot reload:

just dev

This starts:

  • Database (MongoDB or SQL, if configured)
  • Redis (if background jobs enabled)
  • FastAPI application with auto-reload
  • Frontend asset compilation with watch mode (tailwind + bundler watchers spawned inside the app container)

Changes to Python code, templates, and assets automatically reload — the container syncs from the host via docker compose up --watch, granian restarts workers on Python edits, and bun watchers rebuild bundle.css/bundle.js on template/JS edits.

The host port is derived deterministically from the project directory (in the 14000–17999 band), so two scaffolded projects can run just dev side-by-side without colliding on 8000. The mapped port is printed when the container starts; docker compose -f compose.dev.yml ps also shows it.

Tailwind v4 quirk: edits to config.css itself don't trigger a rebuild — restart just dev after changing it. Edits to templates and other @source-watched directories rebuild without restart. See tailwindlabs/tailwindcss#14726.

Local Development

Run services locally without Docker:

just install-deps            # Run once after cloning or updating lockfiles
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.

local-all also derives a deterministic port (in the 10000–13999 band for the frontend, 20000–23999 for the worker UI), distinct from just dev's 14000–17999 docker band. So just local-all (running against your shared prod-style services) and just dev (running fully containerized against local mongo/redis) can be alive simultaneously without port conflict.

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 at the repo root (template + tooling)
just type-check-py           # Type check the vibetuner-py framework code 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
just i18n-fuzzy-audit        # Report any fuzzy-marked entries across catalogs

update-locale-files runs msgmerge --no-fuzzy-matching, so new or changed strings land with an empty msgstr (an honest "untranslated" that falls back to the English msgid) rather than a confident wrong guess copied from a textually similar entry. Run just i18n-fuzzy-audit to find any leftover #, fuzzy entries — gettext ignores them at runtime, but they read as real translations in the .po file and should be cleaned up.

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

just update-scaffolding      # Update project to latest vibetuner template
just deps-scaffolding        # Update deps + scaffolding on the current branch (no PR)
just deps-scaffolding-pr     # Update deps + scaffolding in a worktree and open a PR

Common Tasks

Adding New Routes

Create a new file in src/app/frontend/routes/. Routes are automatically discovered, no registration needed:

# src/app/frontend/routes/blog.py
from fastapi import APIRouter

router = APIRouter(prefix="/blog", tags=["blog"])

@router.get("/")
async def list_posts():
    return {"posts": []}

The framework finds any router variable in route files and registers it automatically.

@render Decorator

For simple routes, use @render() to eliminate render_template() boilerplate. Return a dict and the decorator handles rendering:

from vibetuner import render

@router.get("/dashboard")
@render("dashboard.html.jinja")
async def dashboard(request: Request, user=Depends(get_current_user)) -> dict:
    return {"user": user}

The decorator auto-extracts request from route parameters. If the route returns a Response object (e.g. RedirectResponse) instead of a dict, it passes through unchanged — this is the escape hatch for conditional logic:

@router.get("/items/{id}")
@render("items/detail.html.jinja")
async def item_detail(request: Request, id: str) -> dict:
    item = await Item.get(id)
    if not item:
        return RedirectResponse("/items")  # Passed through as-is
    return {"item": item}

Streaming Large Pages

For large pages (dashboards, data tables), use render_template_stream() to send HTML chunks as the template renders. The browser can start painting the <head> and initial layout before the full page is ready:

from vibetuner import render_template_stream

@router.get("/dashboard")
async def dashboard(request: Request):
    data = await get_dashboard_data()
    return render_template_stream("dashboard.html.jinja", request, {"data": data})

Context merging works identically to render_template(). Best suited for full page loads — HTMX partials are typically small and don't benefit from streaming.

Adding Database Models

Create models in src/app/models/. Models are automatically discovered and initialized.

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"

No __init__.py registration needed. The framework auto-discovers Beanie Documents.

Beanie class-level queries and ty

Beanie builds queries from class-level field access (Post.title == "x", Eq(cls.author.id, user.id)). Static type checkers don't model this: ty resolves Model.field from the field's instance annotation, not as a query expression. Most comparisons type-check fine, but two patterns surface a spurious unresolved-attribute from just lint. The first is an optional link used in a query: field: Link[X] | None with cls.field.id reports id is not defined on None. This one is fundamental, since ty rejects attribute access on any X | None, so it can't be fixed by changing the field type or the Link alias. The second is a query inside a classmethod on a non-Document mixin (a BaseModel mixin), where cls.find and cls.field aren't known to the checker. Silence these on the exact line with an inline directive, e.g. await cls.find_one(Eq(cls.author.id, user.id)) # ty: ignore[unresolved-attribute]. ty flags directives that stop matching (unused-ignore-comment), so keep the comment on the line that errors and drop it once it's no longer needed.

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)

Register SQL models explicitly in tune.py:

# src/app/tune.py
from vibetuner import VibetunerApp
from app.models.post import Post

app = VibetunerApp(
    sql_models=[Post],
)

For SQL databases, create tables with: vibetuner db create-schema

MongoDB is optional

Projects that only use SQLModel/Postgres do not need MONGODB_URL. The framework silently skips MongoDB initialization when the URL is not set.

Soft Delete

Instead of permanently removing documents, soft delete marks them with a deleted_at timestamp. Soft-deleted documents stay in the database but are automatically excluded from queries.

Use DocumentWithSoftDelete instead of Document as your base class:

# src/app/models/post.py
from vibetuner.models import DocumentWithSoftDelete
from vibetuner.models.mixins import TimeStampMixin

class Post(DocumentWithSoftDelete, TimeStampMixin):
    title: str
    content: str

    class Settings:
        name = "posts"

This adds an optional deleted_at field to your document automatically.

Deleting and querying:

post = await Post.find_one(Post.title == "Draft")

await post.delete()          # sets deleted_at, document stays in DB
post.is_deleted()            # True

await Post.find_all().to_list()          # excludes soft-deleted documents
await Post.find_many_in_all().to_list()  # includes soft-deleted documents

await post.hard_delete()     # permanently removes the document

find(), find_one(), and get() all automatically filter out soft-deleted documents. Use find_many_in_all() when you need to access them (e.g., admin views, audit logs).

Restoring a soft-deleted document:

post.deleted_at = None
await post.save()

CRUD factory: The DELETE endpoint automatically performs a soft delete for models that use DocumentWithSoftDelete. No configuration needed.

Encrypted Fields

Encrypt sensitive fields at rest using EncryptedFieldsMixin and the EncryptedStr type. Fields are transparently encrypted before database writes and decrypted on load:

from beanie import Document
from pydantic import Field
from vibetuner.models.mixins import EncryptedFieldsMixin, EncryptedStr

class ApiCredential(Document, EncryptedFieldsMixin):
    provider: str
    api_key: EncryptedStr = Field(..., description="Encrypted API key")
    token: EncryptedStr | None = Field(default=None)

    class Settings:
        name = "api_credentials"
# Usage — encryption/decryption is automatic
cred = ApiCredential(provider="stripe", api_key="sk_live_xxx")
await cred.insert()         # api_key is encrypted before write

loaded = await ApiCredential.get(cred.id)
print(loaded.api_key)       # "sk_live_xxx" — decrypted on load

Encryption requires the FIELD_ENCRYPTION_KEY environment variable (a Fernet key). When the key is not set, fields are stored as plaintext. Use vibetuner crypto set-key to generate and configure a key, and vibetuner crypto rotate-key to rotate it. See the CLI Reference for details.

Creating Templates

Add templates in templates/frontend/:

<!-- templates/frontend/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 %}

Template Path Convention

The template search path already includes templates/frontend/, so when calling render_template() use paths relative to that directory:

# Correct - path relative to templates/frontend/
render_template("blog/list.html.jinja", request)
render_template("admin/dashboard.html.jinja", request)

# Wrong - "frontend/" prefix is redundant and causes TemplateNotFound
render_template("frontend/blog/list.html.jinja", request)  # TemplateNotFound!

The same convention applies to {% extends %} and {% include %} inside templates:

{% extends "base/skeleton.html.jinja" %}          {# correct #}
{% extends "frontend/base/skeleton.html.jinja" %} {# wrong #}

Passing Context

The user context dict can be passed positionally, as ctx=, or as context= (the two keywords are aliases — context= exists because that's what most Flask/Starlette muscle memory reaches for):

render_template("home.html.jinja", request, {"hero": hero})           # positional
render_template("home.html.jinja", request, ctx={"hero": hero})       # ctx kwarg
render_template("home.html.jinja", request, context={"hero": hero})   # alias

Passing both ctx= and context= raises TypeError. Misspelled or unknown kwargs (e.g. contxt=) also raise TypeError — the signature is explicit, so typos can't silently render with an empty context.

Skeleton Extension Points

The shipped base/skeleton.html.jinja exposes blocks and context variables so projects can slot in customisations without copying the whole skeleton. Extending it is preferable to overriding it: upstream changes (CSP nonce, theming, etc.) flow through automatically.

Blocks (override in any child template):

Block Position Use for
extra_head_links After <title>, before bundle.css <link rel="alternate"> feeds, RSS, custom meta tags
extra_scripts After the bundled <script> (and optional umami) Per-app scripts that don't replace bundle.js
before_main Inside <body>, before block body Dev banners, sticky overlays
after_main Inside <body>, after block body Persistent mini-players, floating toolbars

Existing blocks already there: title, head, scripts, start_of_body, header, body, content, footer, end_of_body.

Context variables (set via register_globals, a context provider, or per-render ctx):

Variable Type Default Effect
color_scheme str "light" Sets <meta name="color-scheme"> content
canonical_url str \| None None When set, renders <link rel="canonical" href="…">
font_preloads list[dict] [] Each entry renders <link rel="preload" as="font" href="…" type="…" [crossorigin="…"]>
# tune.py
from vibetuner import register_globals

register_globals({"color_scheme": "dark"})  # whole site is dark
# Per-page canonical URL
return render_template(
    "blog/post.html.jinja",
    request,
    {"canonical_url": str(request.url_for("blog_post", slug=post.slug))},
)
# Self-hosted brand fonts (preload-scanner picks these up before bundle.css)
register_globals({
    "font_preloads": [
        {"href": "/static/fonts/brand.woff2", "type": "font/woff2", "crossorigin": "anonymous"},
    ],
})
<!-- templates/frontend/base/skeleton.html.jinja override -->
{% extends "base/skeleton.html.jinja" %}

{% block extra_head_links %}
    <link rel="alternate" type="application/rss+xml"
          title="My App" href="{{ url_for('rss').path }}" />
{% endblock extra_head_links %}

{% block after_main %}
    {# Persistent player survives HTMX boost swaps #}
    {% include "components/player.html.jinja" %}
{% endblock after_main %}

If you do need to override the whole skeleton (e.g. to wrap the body in an HTMX-boost container, change the <html> attributes, or add markup before <head>), prefer the smallest possible override and keep the upstream blocks intact so future framework changes still apply.

Built-in Template Globals

Vibetuner provides these variables in every template automatically:

Variable Type Value
request Request The current Starlette Request
language str Resolved request language (e.g. "en", "ca")
DEBUG bool Mirrors settings.debug
now datetime datetime.now(timezone.utc) — timezone-aware UTC datetime
today str date.today().isoformat() — ISO date string (e.g., "2026-04-07")
project ProjectConfiguration settings.projectproject_name, company_name, copyright, fqdn, …
brand BrandSettings settings.brand — see Brand Configuration
csp_nonce str Per-request CSP nonce (see Security Headers and CSP Nonce)
hotreload callable Dev-mode hot-reload helper
<p>Page rendered at {{ now | format_datetime }}</p>
<p>Today is {{ today }}</p>
<footer>{{ project.copyright }}</footer>
<title>{{ project.project_name }}</title>

now and csp_nonce are re-evaluated on every render; project and brand are app-level config snapshots resolved at startup. For custom globals, see Template Context Providers.

Built-in Template Filters

Vibetuner provides several built-in template filters for common formatting needs:

Filter Usage Output
timeago {{ dt \| timeago }} "5 minutes ago"
timeago(short=True) {{ dt \| timeago(short=True) }} "5m ago"
format_date {{ dt \| format_date }} "January 15, 2025"
format_datetime {{ dt \| format_datetime }} "January 15, 2025 at 2:30 PM"
format_duration / duration {{ seconds \| duration }} "5 min" or "30 sec"

timeago Filter

The timeago filter converts a datetime to a human-readable relative time string:

<span>Created {{ post.created_at | timeago }}</span>
<!-- Output: "5 minutes ago", "yesterday", "3 months ago", etc. -->

<!-- Short format for compact displays -->
<span>{{ post.created_at | timeago(short=True) }}</span>
<!-- Output: "5m ago", "1d ago", "3mo ago", etc. -->

Short format outputs:

Time Range Short Format
< 60 seconds "just now"
< 60 minutes "Xm ago"
< 24 hours "Xh ago"
< 7 days "Xd ago"
< 30 days "Xw ago"
< 365 days "Xmo ago"
< 4 years "Xy ago"
>= 4 years "MMM DD, YYYY"

Adding Custom Template Filters

Register custom Jinja2 filters via the template_filters dict in VibetunerApp:

# src/app/frontend/templates.py
def uppercase(value):
    """Convert value to uppercase"""
    return str(value).upper()

def format_money(value):
    """Format value as USD currency"""
    try:
        return f"${float(value):,.2f}"
    except (ValueError, TypeError):
        return str(value)
# src/app/tune.py
from vibetuner import VibetunerApp
from app.frontend.templates import uppercase, format_money

app = VibetunerApp(
    template_filters={
        "uppercase": uppercase,
        "money": format_money,
    },
)

Use in templates:

<h1>{{ user.name | uppercase }}</h1>
<p>Price: {{ product.price | money }}</p>

Adding Background Jobs

If you enabled background jobs, create tasks in src/app/tasks/. Task modules are automatically discovered:

# src/app/tasks/emails.py
from vibetuner.tasks.worker import get_worker
from vibetuner.models import UserModel
from vibetuner.services.email import EmailService

worker = get_worker()

@worker.task()
async def send_welcome_email(user_id: str):
    user = await UserModel.get(user_id)
    if user:
        email_service = EmailService()
        await email_service.send_email(
            to_address=user.email,
            subject="Welcome!",
            html_body="<h1>Welcome!</h1>",
            text_body="Welcome!",
        )
    return {"status": "sent"}

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 v4 + DaisyUI. The scaffolded config.css lives at the project root and pulls everything in via the shipped core stylesheet:

/* config.css */
@import "@alltuner/vibetuner/core.css";
@source "templates";
@source "assets/statics/js";

/* Add your custom styles below: */

core.css already imports tailwindcss and registers the daisyui plugin, so you can use any DaisyUI component class and any Tailwind v4 utility in your templates without further configuration. Define Tailwind tokens with @theme { ... } and write component classes with @layer components { .btn-custom { @apply btn btn-primary; } }.

The build process compiles config.css to assets/statics/css/bundle.css on just dev / just local-all / bun run build:css.

Brand palette (build-time)

To recolor DaisyUI's light / dark themes for the whole bundle, override the [data-theme="…"] selectors below the @import:

@import "@alltuner/vibetuner/core.css";
@source "templates";
@source "assets/statics/js";

[data-theme="light"] {
  --color-primary: oklch(64% 0.21 24);
  --color-primary-content: oklch(98% 0 0);
  --color-accent: oklch(82% 0.18 95);
}

[data-theme="dark"] {
  --color-primary: oklch(64% 0.21 24);
  --color-primary-content: oklch(98% 0 0);
  --color-accent: oklch(82% 0.18 95);
}

core.css invokes @plugin "daisyui", which emits rules like :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { … }. The override above shares specificity with the standalone [data-theme=light] matcher and lands later in the file — cascade picks it up automatically. For per-tenant runtime overrides on top of this, see the Theming guide.

When a brand-new named theme is needed

DaisyUI's documented @plugin "daisyui/theme" { name: "my-brand"; … } form lets projects define entirely new themes, but daisyui is a private transitive of @alltuner/vibetuner, so resolution from a consumer-side config.css fails under bun's isolated linker with Error: Can't resolve 'daisyui/theme'. If you genuinely need this (most projects don't — overriding [data-theme="…"] is enough), add daisyui as a direct devDependency: bun add -d daisyui and re-run bun install. The top-level symlink makes @plugin "daisyui/theme" resolvable from config.css.

Brand Configuration

DaisyUI tokens and CSS variables cover everything that renders inside the page, but a few brand surfaces are read before any CSS runs (favicon meta tags, the PWA manifest) or in clients that ignore CSS variables (email clients). BrandSettings is an app-level pydantic-settings surface that drives those specific surfaces:

# .env (all three are optional; defaults shown)
BRAND_PRIMARY_COLOR=#5b2333
BRAND_BROWSER_THEME_COLOR=#ffffff
BRAND_EMAIL_BUTTON_COLOR=  # falls back to BRAND_PRIMARY_COLOR when unset
  • BRAND_PRIMARY_COLOR — Safari pinned-tab mask-icon color, Windows tile color (browserconfig.xml), and the magic-link email button when no override is set.
  • BRAND_BROWSER_THEME_COLOR — mobile browser chrome (<meta name="theme-color">) and the PWA manifest's theme_color / background_color.
  • BRAND_EMAIL_BUTTON_COLOR — override slot for the magic-link email button when it needs to differ from the primary brand color.

Inputs accept any pydantic Color form (named, rgb(), hex short or long); values canonicalise to long-form #rrggbb lowercase before rendering.

from vibetuner.config import settings

settings.brand.primary_color        # HexColor("#5b2333")
settings.brand.browser_theme_color  # HexColor("#ffffff")
settings.brand.email_button         # email_button_color or primary_color

settings.brand is exposed in every Jinja render via the shipped _brand_context provider, so templates read {{ brand.primary_color }} without wiring anything up. The companion _project_context provider does the same for settings.project, so branded chrome (header logos, footers, OpenGraph defaults) can reference {{ project.project_name }}, {{ project.copyright }}, {{ project.fqdn }}, etc. without per-route context boilerplate. BrandSettings is deliberately app-level (favicon assets are static files served before tenant resolution; the email service does not see request context). For per-tenant in-page colors, use TenantTheme.

Security Headers and CSP Nonce

Vibetuner includes SecurityHeadersMiddleware that sets security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, etc.) and generates a Content Security Policy with a unique nonce per request.

Script tags get the nonce auto-injected by the middleware. Do not add nonce= attributes to <script> tags manually:

<!-- Correct — nonce is added automatically by middleware -->
<script src="/static/app.js"></script>

<!-- Wrong — manual nonce causes double-injection issues -->
<script nonce="{{ csp_nonce }}" src="/static/app.js"></script>

Style tags and other elements that need the nonce must use the {{ csp_nonce }} template variable (available in all templates):

<style nonce="{{ csp_nonce }}">
    .highlight { color: red; }
</style>

CSP is fully enforced in both production and debug mode by default, so violations break the page locally and are caught before they ship. To fall back to the legacy soft mode (where debug emits Content-Security-Policy-Report-Only), set CSP_ENFORCE_CSP_IN_DEBUG=false.

Configure extra allowed sources via environment variables:

Variable Description
CSP_EXTRA_SCRIPT_SRC Additional script sources
CSP_EXTRA_STYLE_SRC Additional style sources
CSP_EXTRA_IMG_SRC Additional image sources
CSP_EXTRA_CONNECT_SRC Additional connect sources
CSP_EXTRA_FONT_SRC Additional font sources
CSP_EXTRA_MEDIA_SRC Additional media sources
CSP_ENFORCE_CSP_IN_DEBUG Enforce CSP in debug mode (default: true)
CSP_STYLE_SRC_STRICT Use the request nonce on style-src instead of 'unsafe-inline' (default: false)

Nosniff Content-Type guard. The middleware always sets X-Content-Type-Options: nosniff. If a response has no Content-Type header (e.g. a bare Response(status_code=404) with no media_type=), the middleware fills in text/plain; charset=utf-8 so browsers do not turn the response into an error page or a 0-byte download. App code should still set media_type= explicitly when it matters, but the guard prevents the worst-case UX when something slips through.

htmx CSP Protection (default-on)

Requires [email protected]

The hx-csp extension file ships with [email protected], pulled transitively by the matching @alltuner/vibetuner release, and is re-exported as @alltuner/vibetuner/htmx/csp (added in 10.20.0). When upgrading from an older release that shipped htmx beta3, the extension was carried as hx-nonce.js; see Beta3 to Beta4 Changes for the import-path update.

htmx 4.0.0-beta4 ships an hx-csp extension (renamed from hx-nonce in beta3) that gates htmx attribute processing behind the page CSP nonce. It is loaded by default from the framework-managed block of config.js, alongside hx-preload and hx-live:

import "@alltuner/vibetuner/htmx/csp";

You do not add this yourself — it ships in the part of config.js you must not edit. The HTML attribute is still named hx-nonce; only the extension itself was renamed.

Why it is required, not optional. Vibetuner's CSP is script-src 'nonce-…' 'strict-dynamic' with no 'unsafe-eval'. A nonce plus strict-dynamic does not permit eval / new Function — only 'unsafe-eval' does. In beta4, both hx-on: and hx-live evaluate their JS through htmx core's new Function(), so without mitigation every such expression throws an EvalError under the enforced CSP and silently does nothing (an easy-to-miss runtime console error, not a build failure — and CSP is enforced in debug too unless you opt out). hx-csp fixes this: the framework-managed import flips htmx.config.safeEval = true before the extension registers, and with safeEval the extension replaces htmx's new Function() evaluation with nonce-based <script> injection — which a nonce + strict-dynamic CSP does permit. That is what makes hx-on: and hx-live genuinely CSP-safe with no 'unsafe-eval'. No safeEval meta tag is required; it is enabled by the framework config.

Fail-closed nonce gating, auto-stamped. The extension is fail-closed: every element carrying an hx-* attribute must have an hx-nonce attribute whose value matches the page CSP nonce, or htmx strips its hx-* attributes. You do not stamp this manually. SecurityHeadersMiddleware auto-stamps hx-nonce on every htmx element in HTML responses, exactly the way it already injects the nonce into <script> tags. There is no body_attrs nonce to add and no per-element hx-nonce="{{ csp_nonce }}" to write.

The gate trusts what the server renders

Because the middleware stamps the nonce onto the rendered body, the gate trusts whatever the server renders — the same trust model as the existing script-nonce injection. If your project renders untrusted HTML (markdown, user-supplied rich content), you must sanitize it; hx-csp is not a substitute for sanitizing injected hx-* / <script> in that content.

Trusted Types (further hardening): You can also tell the browser to refuse non-htmx HTML sinks by adding require-trusted-types-for 'script'; trusted-types htmx to your CSP. Configure this via CSP_EXTRA_* if you need to merge it with other directives, or extend SecurityHeadersMiddleware directly. With hx-csp default-on this works as-is; if you add a trusted-types directive it must include htmx in the allowlist.

Strict style-src (opt-in)

Requires vibetuner ≥ 10.11.0

SecurityHeadersSettings only learned about CSP_STYLE_SRC_STRICT in 10.11.0. On older releases the env var is silently ignored (pydantic's extra="ignore" discards unknown CSP options), so the style-src directive keeps emitting 'unsafe-inline' without any warning. Bump vibetuner in pyproject.toml and re-run uv sync before relying on the flag.

By default, style-src is 'self' 'unsafe-inline' so that inline style="..." attributes and unnonced <style> tags work. This is the permissive baseline that lets existing templates ship without changes.

Set CSP_STYLE_SRC_STRICT=true to switch style-src to 'self' 'nonce-<request-nonce>'. This is a meaningful hardening: inline style="..." attributes will be blocked by the browser, and every <style> tag must carry nonce="{{ csp_nonce }}".

The framework's own templates already follow this pattern (the inline styles in skeleton.html.jinja and theme.html.jinja are nonced; the htmx 4.0.0-beta4 indicator stylesheet uses a constructable CSSStyleSheet which does not need 'unsafe-inline'). Before flipping this flag in your project:

  1. Audit your templates for style="..." attributes — convert to utility classes or scoped <style> blocks.
  2. Make sure every <style> tag carries nonce="{{ csp_nonce }}".
  3. Test interactively: open the browser devtools and look for CSP violations on hover/focus/error states (these often have inline styles set by JS).

This default may flip to true in a future major version once the ecosystem has had time to migrate.

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
    })

HTMX Request Detection

Every request has request.state.htmx available (provided by the starlette-htmx middleware). Use it to serve different responses for HTMX vs regular requests:

from fastapi import Request
from starlette.responses import HTMLResponse
from vibetuner import render_template, render_template_string

@router.get("/items")
async def list_items(request: Request):
    items = await Item.find_all().to_list()
    ctx = {"items": items}

    if request.state.htmx:
        # HTMX request — return just the partial
        html = render_template_string("items/_list.html.jinja", request, ctx)
        return HTMLResponse(html)

    # Regular request — return the full page
    return render_template("items/list.html.jinja", request, ctx)

Available properties on request.state.htmx:

Property Description
bool(request.state.htmx) True if this is an HTMX request
.boosted True if request came via hx-boost
.target ID of the target element (hx-target)
.trigger ID of the element that triggered the request
.trigger_name Name attribute of the triggering element
.current_url Browser's current URL when request was made
.prompt User response from hx-prompt

HTMX Response Headers

Helper functions for setting HTMX response headers. Import from vibetuner.htmx:

from vibetuner.htmx import hx_redirect, hx_location, hx_trigger

# Full-reload redirect (when <head> or scripts differ)
return hx_redirect("/items/123")

# HTMX-style navigation without full reload
return hx_location("/items", target="#main", swap="innerHTML")

# Trigger client-side events after swap
response = render_template("items/created.html.jinja", request, ctx)
hx_trigger(response, "itemCreated", {"id": str(item.id)})
return response

Available helpers: hx_redirect, hx_location, hx_trigger, hx_trigger_after_settle, hx_trigger_after_swap, hx_push_url, hx_replace_url, hx_reswap, hx_retarget, hx_refresh.

JSON serialization is handled internally — you never need to call json.dumps().

Response Caching (Server-Side)

Use the @cache decorator to cache route responses in Redis with a configurable TTL. This is ideal for expensive queries, aggregation endpoints, or rendered pages that don't change on every request:

from vibetuner.cache import cache

@router.get("/api/stats")
@cache(expire=60)  # cache for 60 seconds
async def get_stats(request: Request):
    return {"users": await count_users()}

The decorator uses vibetuner's existing Redis connection — no extra setup required if you already have REDIS_URL configured.

Key features:

  • Cache key derived from route path + sorted query parameters
  • Respects Cache-Control: no-cache request header (bypasses cache)
  • Works with JSON, HTML, and dict responses
  • Disabled by default in debug mode — pass force_caching=True to override
  • If Redis is not configured or unavailable, the decorator is a transparent no-op

Request-dependent cache keys with vary_on:

Use vary_on to cache different responses for different users, tenants, or any other request-derived dimension:

# Per-user cache — each user gets their own cached dashboard
@router.get("/dashboard")
@cache(expire=120, vary_on=lambda r: str(r.state.user.id))
async def dashboard(request: Request):
    return await render_dashboard(request)

# Per-tenant cache
@router.get("/reports")
@cache(expire=300, vary_on=lambda r: r.state.tenant_id)
async def reports(request: Request):
    return await generate_reports(request)

# Vary by header
@router.get("/api/data")
@cache(expire=60, vary_on=lambda r: r.headers.get("x-tenant", ""))
async def data(request: Request):
    return await fetch_data(request)

vary_on accepts a callable with signature (Request) -> str. The returned string is included in the cache key, so different values produce independent cache entries. When None (the default), all requests to the same path and query share one cache entry.

Cache invalidation:

from vibetuner.cache import invalidate, invalidate_pattern

# Invalidate a specific path
await invalidate("/api/stats")

# Invalidate a specific query variant
await invalidate("/api/stats", query_params="page=1")

# Invalidate all matching paths (uses Redis SCAN)
await invalidate_pattern("/api/*")

Cache Control Headers (Browser-Side)

Use the @cache_control decorator to set Cache-Control HTTP headers declaratively instead of manually manipulating response headers:

from vibetuner.decorators import cache_control

@router.get("/static-page")
@cache_control(max_age=300, public=True)
async def static_page(request: Request):
    return render_template("static_page.html.jinja", request)

Supported directives: public, private, no_cache, no_store, max_age, s_maxage, must_revalidate, stale_while_revalidate, immutable.

You can combine both decorators — @cache for server-side Redis caching and @cache_control for browser-side HTTP caching:

@router.get("/api/stats")
@cache(expire=60)
@cache_control(max_age=30, public=True)
async def get_stats(request: Request):
    return {"users": await count_users()}

Block Rendering for HTMX Partials

Use render_template_block() to render a single {% block %} from a template, enabling one template to serve both full-page and HTMX partial responses:

<!-- templates/frontend/items/list.html.jinja -->
{% extends "base/skeleton.html.jinja" %}
{% block body %}
<div id="items-container">
    {% block items_list %}
    {% for item in items %}
        <div class="item">{{ item.name }}</div>
    {% endfor %}
    {% endblock items_list %}
</div>
{% endblock body %}
from vibetuner import render_template, render_template_block

@router.get("/items")
async def list_items(request: Request):
    items = await Item.find_all().to_list()
    ctx = {"items": items}

    if request.state.htmx:
        return render_template_block(
            "items/list.html.jinja", "items_list", request, ctx
        )

    return render_template("items/list.html.jinja", request, ctx)

For HTMX out-of-band swaps that update multiple page regions in one response, use render_template_blocks() (plural):

from vibetuner import render_template_blocks

@router.post("/items")
async def create_item(request: Request):
    item = await Item.insert(...)
    items = await Item.find_all().to_list()
    ctx = {"items": items, "item_count": len(items)}

    return render_template_blocks(
        "items/list.html.jinja",
        ["items_list", "notification_badge"],
        request, ctx,
    )

HTMX-Only Routes

Use the require_htmx dependency to reject non-HTMX requests with a 400 error:

from fastapi import Depends
from vibetuner.frontend.deps import require_htmx

@router.post("/items/create", dependencies=[Depends(require_htmx)])
async def create_item(request: Request):
    # Only reachable via HTMX — non-HTMX requests get 400
    ...

Using hx-boost

For links and forms that should use HTMX navigation without writing custom hx-get/hx-post attributes, use hx-boost="true" on a parent element. Boosted links and forms swap the <body> content and update the URL without a full page reload:

<nav hx-boost="true">
    <a href="/dashboard">Dashboard</a>
    <a href="/settings">Settings</a>
</nav>

Boosted requests set request.state.htmx.boosted = True. Since boosted requests expect a full page response (they swap the entire body), you typically don't need to branch on request.state.htmx for boosted routes.

Internationalization

Framework-Shipped Translations

The vibetuner package ships compiled translation catalogs for its built-in templates (login, profile, magic-link, etc.). When your app sets a supported locale, framework strings translate automatically — you do not need to re-extract _() calls from vibetuner/templates/ into your own catalog.

At startup, frontend/middleware.py loads catalogs in this order:

  1. Framework catalogs (vibetuner/locales/<lang>/LC_MESSAGES/messages.mo) — bundled with the package, ship for the locales the framework supports (en, ca, with more coming).
  2. Project catalogs (<project>/locales/<lang>/LC_MESSAGES/messages.mo) — your app's own translations.

Both catalogs use the default messages domain and merge per locale, with project catalogs winning on collision. Practical implications:

  • Set your project locale to ca and /auth/login renders in Catalan out of the box — no extraction required.
  • If you don't like the framework's wording for a string, redefine it in your project's own messages.po. Your translation overrides the framework's at runtime.
  • If you support a locale the framework doesn't yet ship, framework strings fall back to the English msgid (gettext's NullTranslations behavior). You can either translate those strings yourself (extract them locally, override in your catalog) or contribute the locale upstream.

Contributing a Framework Locale

To add a new framework locale (e.g. Spanish):

just extract-framework-translations  # refresh messages.pot
just new-framework-locale es         # init es/LC_MESSAGES/messages.po
# Edit vibetuner-py/src/vibetuner/locales/es/LC_MESSAGES/messages.po
just compile-framework-locales       # produce messages.mo

The full workflow is also wrapped as just i18n-framework. Both .po and .mo files are committed to the repo so end users don't need pybabel at install time — the wheel ships the .mo files directly.

update-framework-locales uses msgmerge --no-fuzzy-matching so new strings stay empty rather than inheriting a wrong guess from a similar entry; just i18n-framework-fuzzy-audit reports any #, fuzzy entries in the framework catalogs. The recipes pass --no-wrap so entries stay single-line and don't drift when gettext's line-wrapping differs between macOS and the Linux used in CI.

Extracting Translations

After adding translatable strings to your code or templates:

just extract-translations

This scans your code and templates for {% trans %} blocks and gettext() calls.

Catalog references record the source file only (#: templates/frontend/index.html.jinja), not the line number. Moving a translatable string within a file therefore produces no catalog churn, so a pure line shift never drifts messages.pot or your .po files.

The recipes also pass --no-wrap, so every msgid/msgstr stays on a single line. GNU gettext's default line wrapping varies between versions and platforms (macOS vs the Linux used in CI), which would otherwise rewrap long strings differently and drift the catalogs between a developer's machine and CI. Single-line entries are deterministic everywhere.

Adding New Languages

just new-locale es  # Spanish
just new-locale fr  # French

Updating Translations

Edit .po files in translations/:

# translations/es/LC_MESSAGES/messages.po
msgid "Welcome"
msgstr "Bienvenido"

Compile translations:

just compile-locales

Using in Templates

{% trans %}Welcome to {{ app_name }}{% endtrans %}

Using in Python

from starlette_babel import gettext as _
message = _("Welcome to {app}", app=app_name)

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
language str The active language for the current request

A language_picker() Jinja global is also available — see below.

When to use which

locale_names and language_picker() overlap but solve different problems:

  • locale_names — locale-independent map of native names, frozen at app startup and sorted by name. Use it when every language must always render in its own script (e.g. a footer that reads "English / Català / Español" regardless of the visitor's language).
  • language_picker()[{code, name}] list with names rendered in the current request locale (or an explicit display_locale). Use it for switchers that should render themselves in the user's active language.

If you want native names but prefer a single source of truth, call language_picker(code) once per language instead of reading locale_names.

Using language_picker() for Locale-Aware Switchers

language_picker() is a Jinja global (also importable as vibetuner.i18n.language_picker) that returns a sorted list of {code, name} entries with names rendered in the current request's locale. Browsing in Spanish gives "inglés / español / catalán"; browsing in Catalan gives "anglès / espanyol / català".

<select name="language">
    {% for entry in language_picker() %}
        <option value="{{ entry.code }}"
                {% if entry.code == language %}selected{% endif %}>
            {{ entry.name }}
        </option>
    {% endfor %}
</select>

Pass an explicit display_locale to render names in a specific language regardless of the request locale: {% for e in language_picker("es") %}.

Using locale_names for native names

locale_names is locale-independent — each language is shown in its own native name (e.g. {"ca": "Català", "en": "English", "es": "Español"}). Use this when you want a consistent display regardless of the user's current language.

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):

  1. Custom resolvers registered via register_locale_resolver
  2. Query parameter (?l=es)
  3. URL path prefix (/ca/...)
  4. User preference (from session, for authenticated users)
  5. Cookie (language cookie)
  6. Accept-Language header (browser preference)
  7. Default language

The Accept-Language step negotiates region-aware: each browser preference is matched against the supported languages by its full tag first, then by its language-only subtag, so a region-qualified top preference like ca-ES resolves to a supported ca instead of losing to a lower-ranked exact match such as es. The highest-quality preference with any supported match wins.

Custom Locale Resolvers (register_locale_resolver)

For per-tenant or domain-specific locale rules, register a custom resolver at startup. Resolvers run before all built-in selectors and the first one to return a non-None value wins. Within the registered group, resolvers are ordered by priority ascending (lower runs first).

from vibetuner.i18n import register_locale_resolver

def tenant_locale(conn):
    tenant = getattr(conn.scope.get("state", {}), "tenant", None)
    return tenant.language if tenant else None

register_locale_resolver(tenant_locale)

Resolvers must be synchronous (do any I/O upstream in middleware). If a resolver raises, the exception is logged and the chain falls through to the next resolver — a bad lookup never produces a 500.

Forcing a Language Mid-Request (set_request_language)

To change the active language partway through a request (e.g. right after a session login), use set_request_language. It updates both the Babel context (drives {% trans %}) and request.state.language (drives <html lang> and the Content-Language header) in one call so they stay in sync.

from vibetuner.i18n import set_request_language

set_request_language(request, user.preferred_language)

The code is normalized to lowercase and validated; an invalid code raises ValueError.

Programmatic Language Picker (language_picker)

When you need the picker output outside a template (e.g. JSON endpoint, email rendering), call language_picker directly. By default the names are rendered in the current request's locale.

from vibetuner.i18n import language_picker

choices = language_picker()  # display in current locale
es_choices = language_picker(display_locale="es")  # always in Spanish

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.

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 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 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):

<a href="{{ url_for_language(request, 'es', 'privacy') }}">Español</a>
<!-- Output: /es/privacy -->

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

CRUD Factory

Generate complete REST API endpoints from Beanie Document models with a single function call.

Basic Usage

from vibetuner.crud import create_crud_routes
from app.models.post import Post

post_routes = create_crud_routes(Post, prefix="/posts", tags=["posts"])

Include the router in your app via tune.py. CRUD routes are JSON API endpoints, so use api_routes to keep them visible in /docs:

# src/app/tune.py
from vibetuner import VibetunerApp
from app.routes.posts import post_routes

app = VibetunerApp(api_routes=[post_routes])

This generates five endpoints:

Method Path Description
GET /posts List with pagination, filtering, search, sort
POST /posts Create a new document
GET /posts/{item_id} Read a single document
PATCH /posts/{item_id} Partial update
DELETE /posts/{item_id} Delete a document

Filtering, Searching, and Sorting

post_routes = create_crud_routes(
    Post,
    prefix="/posts",
    sortable_fields=["created_at", "title"],
    filterable_fields=["status", "author_id"],
    searchable_fields=["title", "content"],
    page_size=25,
    max_page_size=100,
)

Query examples:

  • GET /posts?status=published — equality filter
  • GET /posts?search=python — text search across searchable fields
  • GET /posts?sort=-created_at,title — sort descending by date, then title
  • GET /posts?offset=20&limit=10 — pagination
  • GET /posts?fields=title,status — sparse field selection

Lifecycle Hooks

Attach async callbacks to intercept create, update, and delete operations:

async def check_permissions(doc, data, request):
    if request.state.user.id != str(doc.author_id):
        raise HTTPException(403, "Not your post")
    return data

post_routes = create_crud_routes(
    Post,
    prefix="/posts",
    pre_update=check_permissions,
    post_create=notify_subscribers,
)

Available hooks:

Hook Signature
pre_create async (data, request) -> data
post_create async (doc, request)
pre_update async (doc, data, request) -> data
post_update async (doc, request)
pre_delete async (doc, request)
post_delete async (doc, request)

Custom Schemas

Override the auto-generated Pydantic schemas for create/update payloads or response serialization:

from pydantic import BaseModel

class PostCreate(BaseModel):
    title: str
    content: str

class PostResponse(BaseModel):
    id: str
    title: str
    status: str

post_routes = create_crud_routes(
    Post,
    create_schema=PostCreate,
    response_schema=PostResponse,
)

Restricting Operations

Only generate the endpoints you need:

from vibetuner.crud import create_crud_routes, Operation

# Read-only API
post_routes = create_crud_routes(
    Post,
    operations={Operation.LIST, Operation.READ},
)

Route-Level Dependencies

Apply FastAPI dependencies (e.g., authentication) to all generated routes:

from vibetuner.frontend.auth import require_auth

post_routes = create_crud_routes(
    Post,
    dependencies=[require_auth],
)

SSE / Real-Time Streaming

Server-Sent Events helpers for pushing real-time updates to the browser, designed for use with HTMX.

Channel-Based Streaming

Subscribe clients to a named channel and broadcast updates from anywhere:

from vibetuner.sse import sse_endpoint, broadcast

router = APIRouter()

# Endpoint that auto-subscribes to the "notifications" channel
@sse_endpoint("/events/notifications", channel="notifications", router=router)
async def notifications_stream(request: Request):
    pass  # channel kwarg handles the subscription

SSE path is relative to router prefix

The path argument is relative to the router's prefix, just like @router.get(). If your router has prefix="/api", use path="/events" (not path="/api/events") to avoid a doubled path like /api/api/events.

Broadcast from any route or background task:

from vibetuner.sse import broadcast

@router.post("/posts")
async def create_post(request: Request):
    post = await Post(**data).insert()
    # Push HTML fragment to all subscribers
    await broadcast(
        "notifications",
        "new-post",
        data="<div>New post created!</div>",
    )
    return post

Dynamic Channels

Return a channel name from the decorated function for per-resource streams:

@sse_endpoint("/events/room/{room_id}", router=router)
async def room_stream(request: Request, room_id: str):
    return f"room:{room_id}"  # subscribe to this channel

Template-Rendered Broadcasts

Broadcast rendered Jinja2 partials instead of raw strings:

await broadcast(
    "feed",
    "new-post",
    template="partials/post_card.html.jinja",
    request=request,
    ctx={"post": post},
)

Generator-Based Streaming

For full control, yield events directly from an async generator:

@sse_endpoint("/events/clock", router=router)
async def clock_stream(request: Request):
    while True:
        yield {"event": "tick", "data": datetime.now().isoformat()}
        await asyncio.sleep(5)

HTMX Integration

Connect an SSE endpoint to HTMX using the built-in SSE support. The hx-sse:connect attribute opens the stream; htmx v4 then handles messages two ways depending on whether the broadcast carries an event name:

  • Named events (the event you pass to broadcast()) are dispatched as DOM events on the connecting element. You consume them elsewhere with hx-trigger="<event> from:#<id>", typically to re-fetch current state.
  • Unnamed messages (an empty event name) are swapped into the connecting element directly using its own hx-target / hx-swap.

The recommended pattern broadcasts a named event as a signal and lets a consumer fetch the current state, so the rendered markup always reflects the server rather than a fragment that can be missed:

<!-- Opens the stream; carries no visible content itself -->
<div id="notifications-stream" hx-sse:connect="/events/notifications"></div>

<!-- Re-fetches current state whenever the "new-post" event fires -->
<div hx-get="/notifications"
     hx-trigger="new-post from:#notifications-stream">
    <!-- current notifications render here -->
</div>
# Broadcast the signal; the consumer re-fetches the current state.
await broadcast("notifications", "new-post")

Backgrounded Tabs Drop Events — Resync on Reconnect

Live views go stale when the tab is backgrounded

For hx-sse:connect, htmx v4 enables pauseOnBackground by default: when the tab is hidden it closes the stream, and reopens it when the tab is visible again. Any event broadcast() during the hidden window is lost — there is no automatic replay — so the view stays stuck on its old state until the next event or a manual reload. This is easy to miss in local testing (focused tabs never pause) and most visible with fast transitions, where a status flip lands during a glance away.

Recover by re-fetching current state on every (re)connection, not just on the named event. The htmx:after:sse:connection event fires on the connecting element on each connect and reconnect, so add it to the consumer's trigger:

<div id="notifications-stream" hx-sse:connect="/events/notifications"></div>

<div hx-get="/notifications"
     hx-trigger="new-post from:#notifications-stream,
                 htmx:after:sse:connection from:#notifications-stream">
    <!-- current notifications; re-fetched on each event and every reconnect -->
</div>

The reconnect fetch is one idempotent request that returns the view to the current server state, picking up anything missed while hidden. Because the consumer always renders server state, this stays correct no matter how many events were dropped.

To keep a stream live in the background instead (at the cost of holding the connection open in hidden tabs), disable the pause per element with hx-config:

<div hx-sse:connect="/events/notifications"
     hx-config="sse.pauseOnBackground:false"></div>

or globally in your entry point with htmx.config.sse = {pauseOnBackground: false}.

Multi-Worker Support

When Redis is configured (REDIS_URL), broadcasts are relayed across all worker processes via Redis pub/sub automatically. No extra setup needed.

sse_endpoint(buffer_size=...) enables a per-channel ring buffer so a client reconnecting with a Last-Event-ID header can replay missed events. Note that event IDs are per-process and monotonic, so across multiple frontend workers a reconnect that lands on a different worker cannot replay reliably. The resync-on-reconnect pattern above is the robust answer regardless of worker count, and it does not depend on buffer_size.

Reverse Proxies and CDNs

SSE responses are sent with Cache-Control: no-cache and X-Accel-Buffering: no, which tell reverse proxies and CDNs (Caddy, nginx, Cloudflare) to stream events through immediately instead of buffering the response. Without these headers a buffering proxy can hold the connection open and deliver nothing to the client until it closes, making live updates appear to "only work on reload." Because the framework sets them for you, no extra proxy configuration is required for SSE to work in production.

Template Context Providers

Inject variables into every render_template() call without passing them explicitly each time.

Static Globals

Use register_globals() to set values available in all templates:

# src/app/config.py
from vibetuner.rendering import register_globals

register_globals({
    "site_title": "My App",
    "og_image": "/static/og.png",
    "support_email": "[email protected]",
})

Values are merged into the template context on every render. Explicit context passed to render_template() takes precedence over globals.

Dynamic Context Providers

Use @register_context_provider for values that are computed at render time:

from vibetuner.rendering import register_context_provider
from vibetuner.runtime_config import get_config

@register_context_provider
def feature_flags() -> dict[str, Any]:
    return {"show_beta_banner": True}

# Also works with parentheses
@register_context_provider()
def analytics_context() -> dict[str, Any]:
    return {"analytics_id": "UA-XXX"}

Provider functions must return a dict[str, Any]. They run synchronously on every render_template() call. Multiple providers can be registered and their results are merged.

Service Dependency Injection

Vibetuner provides FastAPI Depends() wrappers for built-in services.

Available Services

from fastapi import Depends
from vibetuner.services import (
    get_email_service,
    get_blob_service,
    get_runtime_config,
)

Email Service

from vibetuner.services import get_email_service
from vibetuner.services.email import EmailService

@router.post("/contact")
async def contact(
    email_svc: EmailService = Depends(get_email_service),
):
    await email_svc.send_email(
        to_address="[email protected]",
        subject="Contact form",
        html_body="<p>Hello</p>",
        text_body="Hello",
    )

Blob Storage Service

from vibetuner.services import get_blob_service
from vibetuner.services.blob import BlobService

@router.post("/upload")
async def upload(
    blobs: BlobService = Depends(get_blob_service),
):
    await blobs.put_object(...)

When BlobService() is constructed without an explicit default_bucket, it falls back to R2_DEFAULT_BUCKET_NAME from the environment and logs a one-time process warning naming the resolved bucket. Watch deploy logs for that warning on first boot — if the bucket name doesn't match what this project should write to, the env var has leaked in from another project and uploads will silently land in the wrong bucket. Either set R2_DEFAULT_BUCKET_NAME explicitly for the deploy, or pass default_bucket=... to BlobService().

Runtime Config

from vibetuner.services import get_runtime_config
from vibetuner.runtime_config import RuntimeConfig

@router.get("/settings")
async def app_settings(
    config: RuntimeConfig = Depends(get_runtime_config),
):
    dark_mode = await config.get("features.dark_mode")
    return {"dark_mode": dark_mode}

The dependency automatically refreshes the config cache if it is stale.

Health Check Endpoints

Built-in health endpoints are available at /health for liveness probes, readiness checks, and service diagnostics.

Endpoint Purpose
GET /health Fast liveness check (status + version + uptime)
GET /health?detailed=true Checks all configured services with latency
GET /health/ready Readiness probe — all services must be reachable
GET /health/ping Ultra-fast liveness — returns {"status": "ok"}
GET /health/id Instance identification (slug, port, PID, startup time)

Liveness Check

curl http://localhost:8000/health
# {"status": "healthy", "version": "1.0.0", "uptime_seconds": 3600}

Detailed Health Check

curl http://localhost:8000/health?detailed=true
# {
#   "status": "healthy",
#   "version": "1.0.0",
#   "uptime_seconds": 3600,
#   "services": {
#     "mongodb": {"status": "connected", "latency_ms": 2.1},
#     "redis": {"status": "connected", "latency_ms": 0.5}
#   }
# }

If any service reports an error, the overall status becomes "degraded".

Readiness Probe

Use /health/ready in Kubernetes or Docker health checks to ensure all services are reachable before routing traffic:

# docker-compose.yml
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8000/health/ready"]
  interval: 30s
  timeout: 5s
  retries: 3

Services checked automatically based on configuration: MongoDB, Redis, S3/R2 endpoint, and email (Resend or Mailjet).

Debugging

View Logs

# Docker mode
docker compose logs -f web
# Local mode
# Logs print to stdout

Access Database

# MongoDB
docker compose exec mongodb mongosh

# PostgreSQL
docker compose exec postgres psql -U postgres

Interactive Shell

# Python shell with app context
just shell

Testing

Run Tests

pytest

Test Coverage

pytest --cov=src/app

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"

Built-in Test Fixtures

Vibetuner provides pytest fixtures in vibetuner.testing for common test scenarios. Add this import to your conftest.py:

# tests/conftest.py
from vibetuner.testing import *  # noqa: F403

vibetuner_client — Async HTTP Test Client

Full-stack async test client with middleware, sessions, and HTMX support:

async def test_homepage(vibetuner_client):
    resp = await vibetuner_client.get("/")
    assert resp.status_code == 200

Override vibetuner_app fixture to supply a custom FastAPI app instance.

vibetuner_db — Shared MongoDB Test Database

Creates a single MongoDB database for the whole test session, runs Beanie index registration once, and truncates every non-system collection before and after each test so each test starts with empty collections:

async def test_create_post(vibetuner_db):
    post = Post(title="Test", content="Content")
    await post.insert()
    assert await Post.get(post.id) is not None

The throwaway database is created on the configured Mongo server. By default that is MONGODB_URL, so if your .env points at a remote or production cluster the suite reaches that infrastructure (slow, and not isolated). Set TEST_MONGODB_URL to keep tests on a local Mongo while MONGODB_URL stays pointed at your app's database:

# .env (or .env.local)
MONGODB_URL=mongodb://prod-cluster.internal:27017/
TEST_MONGODB_URL=mongodb://localhost:27017/
# Or stand up a throwaway local Mongo just for the suite
docker run -d --rm -p 27017:27017 mongo:7
TEST_MONGODB_URL=mongodb://localhost:27017/ pytest

When TEST_MONGODB_URL is unset the fixtures fall back to MONGODB_URL, and the session start logs the resolved server and throwaway database name. The test is skipped when neither variable is set.

Caveats:

  • All tests in a session share the same database. Don't assert on database-level state (existence, name, full collection drops) or on indexes being absent.
  • Indexes (including unique constraints) are built once at session scope and persist across tests. DuplicateKeyError is still raised by unique violations.
  • Concurrent runs need pytest-xdist; the session DB name includes the worker id (PYTEST_XDIST_WORKER) so workers don't collide.
  • If a test crashes mid-run, the next test re-truncates on setup so state is self-healing.

mock_auth — Authentication Mocking

Patches the auth backend so requests appear authenticated without real sessions or cookies:

async def test_profile(vibetuner_client, mock_auth):
    mock_auth.login(name="Alice", email="[email protected]")
    resp = await vibetuner_client.get("/user/profile")
    assert resp.status_code == 200

    mock_auth.logout()
    resp = await vibetuner_client.get("/user/profile")
    assert resp.status_code != 200

mock_tasks — Background Task Mocking

Test background task enqueuing without Redis:

from unittest.mock import patch

async def test_signup(vibetuner_client, mock_tasks):
    with patch(
        "app.tasks.emails.send_welcome_email",
        mock_tasks.send_welcome_email,
    ):
        resp = await vibetuner_client.post("/signup", data={...})
    assert mock_tasks.send_welcome_email.enqueue.called

override_config — Runtime Config Overrides

Override RuntimeConfig values for a single test with automatic cleanup:

async def test_feature_flag(override_config):
    await override_config("features.dark_mode", True)
    # Test code that reads the config value

Code Quality

Format Code

just format

Runs:

  • ruff format for Python

Check Code

just lint

Runs:

  • ruff check for Python
  • Type checking
  • Template validation

Environment Configuration

Development Settings

Copy .env.local to .env:

cp .env.local .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

SESSION_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

SESSION_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 wraps copier update plus 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

uv add package-name

Add JavaScript Package

bun add package-name

Sync All Dependencies

just sync

Syncs both Python and JavaScript dependencies.

Common Import Pitfalls

When working with tune.py and the framework internals, certain import patterns cause circular imports. This section explains the rules and how to avoid problems.

Why Circular Imports Happen

The framework loads your tune.py via vibetuner.loader at startup. If tune.py imports a module that itself imports vibetuner.loader (or anything that triggers tune.py to be loaded again), you get a circular import.

Safe vs. Unsafe Imports in tune.py

Import Safe? Notes
from vibetuner.rendering import render_template Yes rendering.py lives outside vibetuner.frontend specifically to avoid cycles
from vibetuner import render_template Yes Re-export of the above
from vibetuner.frontend.templates import render_template No vibetuner.frontend.__init__ imports from loader, which imports tune.py
from vibetuner.frontend import localized No Same reason — triggers vibetuner.frontend.__init__
from vibetuner.frontend.routing import LocalizedRouter Yes Direct submodule import bypasses __init__

Rule of thumb: never import from vibetuner.frontend (the package) in tune.py. Import from specific submodules (vibetuner.frontend.routing, vibetuner.frontend.deps) or use the top-level re-exports (vibetuner.rendering).

Background Task Modules

Task modules listed in VibetunerApp.tasks are imported after model initialization. If a task module imports a model at the top level, the model class must already be defined in src/app/models/:

# src/app/tasks/emails.py
from vibetuner.tasks.worker import get_worker
from app.models.user import User  # OK — models are initialized before tasks load

worker = get_worker()

@worker.task()
async def send_welcome(user_id: str):
    user = await User.get(user_id)
    ...

Lifespan and Lazy Imports

The framework's lifespan() function uses lazy imports to break potential cycles:

# vibetuner/frontend/lifespan.py
async def lifespan(app):
    # Lazy import — tune.py may import vibetuner.frontend submodules
    from vibetuner.loader import load_app_config
    app_config = load_app_config()
    ...

If you provide a custom frontend_lifespan in tune.py, follow the same pattern — use lazy imports for any vibetuner.frontend submodules you need inside the lifespan body:

# src/app/tune.py
from contextlib import asynccontextmanager
from vibetuner import VibetunerApp
from vibetuner.frontend.lifespan import base_lifespan

@asynccontextmanager
async def my_lifespan(app):
    async with base_lifespan(app):
        # Lazy import to avoid circular dependency
        from vibetuner.frontend.hotreload import hotreload  # noqa: F811
        ...
        yield

app = VibetunerApp(frontend_lifespan=my_lifespan)

Quick Checklist

  1. In tune.py, import from vibetuner.* (top-level) or specific submodules, never from vibetuner.frontend as a package.
  2. Task modules can import models at the top level — they load after model init.
  3. Custom lifespans should lazy-import vibetuner.frontend.* inside the function body.

Next Steps