Skip to main content

Reactive Django components using AlpineJS and Django Ninja

Project description

Django Nitro 🚀

Build reactive Django components with AlpineJS - No JavaScript required

Django Nitro is a modern library for building reactive, stateful components in Django applications. Inspired by Django Unicorn and Laravel Livewire, but built on top of AlpineJS and Django Ninja for a lightweight, performant experience.

License: MIT Python 3.12+ Django 5.2+

Why Django Nitro?

  • Zero JavaScript - Write reactive UIs entirely in Python
  • Type-Safe - Full Pydantic integration with generics for bulletproof state management
  • Secure by Default - Built-in integrity verification prevents client-side tampering
  • Lightweight - AlpineJS (~15KB) vs Morphdom (~50KB)
  • Fast - Django Ninja API layer for optimal performance
  • DRY - Pre-built CRUD operations like Django REST Framework's ViewSets

Table of Contents


Installation

pip install django-nitro

Requirements

  • Python 3.12+
  • Django 5.2+
  • django-ninja 1.4.0+
  • pydantic 2.0+

Setup

1. Add to INSTALLED_APPS

# settings.py
INSTALLED_APPS = [
    # ...
    'nitro',
    # your apps here
]

2. Include Nitro API URLs

# urls.py
from django.urls import path
from nitro.api import api

urlpatterns = [
    # ...
    path("api/nitro/", api.urls),  # Important: must be under /api/nitro/
]

3. Add Alpine and Nitro JS to your base template

Option A: Modern (v0.4.0+) - Recommended

<!-- templates/base.html -->
{% load nitro_tags %}
<!DOCTYPE html>
<html>
<head>
    <title>My App</title>
    <!-- Alpine JS (required) -->
    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>

    <!-- Nitro CSS and JS (includes toast styles) -->
    {% nitro_scripts %}
</head>
<body>
    {% block content %}{% endblock %}
</body>
</html>

Option B: Manual

<!-- templates/base.html -->
<!DOCTYPE html>
<html>
<head>
    <title>My App</title>
    <!-- Alpine JS (required) -->
    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
    {% block content %}{% endblock %}

    <!-- Nitro JS (load AFTER Alpine) -->
    <link rel="stylesheet" href="{% static 'nitro/nitro.css' %}">
    <script src="{% static 'nitro/nitro.js' %}"></script>
</body>
</html>

4. (Optional) Configure Nitro Settings (v0.4.0+)

# settings.py
NITRO = {
    'TOAST_ENABLED': True,
    'TOAST_POSITION': 'top-right',  # top-right, top-left, top-center, bottom-right, bottom-left, bottom-center
    'TOAST_DURATION': 3000,  # milliseconds
    'TOAST_STYLE': 'default',  # default, minimal, bordered
}

5. Run collectstatic (if in production)

python manage.py collectstatic

Quick Start

Let's build a simple counter component to understand the basics.

1. Define the Component

# myapp/components/counter.py
from pydantic import BaseModel
from nitro.base import NitroComponent
from nitro.registry import register_component


class CounterState(BaseModel):
    """State schema for the counter component."""
    count: int = 0
    step: int = 1


@register_component
class Counter(NitroComponent[CounterState]):
    template_name = "components/counter.html"
    state_class = CounterState

    def get_initial_state(self, **kwargs):
        """Initialize the component state."""
        return CounterState(
            count=kwargs.get('initial', 0),
            step=kwargs.get('step', 1)
        )

    def increment(self):
        """Action: increment the counter."""
        self.state.count += self.state.step
        self.success(f"Count increased to {self.state.count}")

    def decrement(self):
        """Action: decrement the counter."""
        self.state.count -= self.state.step

    def reset(self):
        """Action: reset to zero."""
        self.state.count = 0

2. Create the Template

<!-- templates/components/counter.html -->
<div class="counter-widget">
    <h2>Counter: <span x-text="count"></span></h2>

    <div class="controls">
        <button @click="call('decrement')" :disabled="isLoading">-</button>
        <button @click="call('reset')" :disabled="isLoading">Reset</button>
        <button @click="call('increment')" :disabled="isLoading">+</button>
    </div>

    <!-- Show loading state -->
    <div x-show="isLoading" class="loading">Updating...</div>

    <!-- Show messages -->
    <template x-for="msg in messages" :key="msg.text">
        <div class="alert" x-text="msg.text"></div>
    </template>
</div>

3. Use in Your View

# myapp/views.py
from django.shortcuts import render
from myapp.components.counter import Counter

def counter_page(request):
    # Initialize the component with custom values
    component = Counter(request=request, initial=10, step=5)
    return render(request, 'counter_page.html', {'counter': component})
<!-- templates/counter_page.html -->
{% extends "base.html" %}

{% block content %}
    <h1>Counter Demo</h1>
    {{ counter.render }}
{% endblock %}

That's it! You now have a fully reactive counter component without writing any JavaScript.


Core Concepts

Django Nitro provides three base classes for different use cases. Choose the one that fits your needs.

NitroComponent (Basic)

Use when: You need full control over state and actions.

Best for: Custom components, widgets, forms that don't map to Django models.

from pydantic import BaseModel, EmailStr
from nitro.base import NitroComponent
from nitro.registry import register_component


class ContactFormState(BaseModel):
    name: str = ""
    email: EmailStr | str = ""
    message: str = ""
    submitted: bool = False


@register_component
class ContactForm(NitroComponent[ContactFormState]):
    template_name = "components/contact_form.html"
    state_class = ContactFormState

    def get_initial_state(self, **kwargs):
        return ContactFormState()

    def submit(self):
        """Custom action to handle form submission."""
        if not self.state.name or not self.state.message:
            self.error("Name and message are required")
            return

        # Send email, save to DB, etc.
        send_contact_email(
            name=self.state.name,
            email=self.state.email,
            message=self.state.message
        )

        self.state.submitted = True
        self.success("Message sent successfully!")
<!-- templates/components/contact_form.html -->
<form x-show="!submitted">
    <input type="text" x-model="name" placeholder="Jearel Alcantara">
    <span x-show="errors.name" x-text="errors.name" class="error"></span>

    <input type="email" x-model="email" placeholder="Your email">

    <textarea x-model="message" placeholder="Your message"></textarea>
    <span x-show="errors.message" x-text="errors.message" class="error"></span>

    <button @click="call('submit')" :disabled="isLoading">
        Send Message
    </button>
</form>

<div x-show="submitted" class="success-message">
    <h3>Thank you!</h3>
    <p>We'll get back to you soon.</p>
</div>

ModelNitroComponent (ORM Integration)

Use when: Your component represents a single Django model instance.

Best for: Detail views, profile editors, single-item forms.

Features:

  • Automatic model loading via pk or id
  • Built-in refresh() method to reload from database
  • Automatic secure field detection (ids and foreign keys)
from django.db import models
from pydantic import BaseModel
from nitro.base import ModelNitroComponent
from nitro.registry import register_component


# Django Model
class BlogPost(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    published = models.BooleanField(default=False)
    author = models.ForeignKey('auth.User', on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)


# Pydantic Schema
class BlogPostSchema(BaseModel):
    id: int
    title: str
    content: str
    published: bool
    author_id: int

    class Config:
        from_attributes = True


# Component
@register_component
class BlogPostEditor(ModelNitroComponent[BlogPostSchema]):
    template_name = "components/blog_post_editor.html"
    state_class = BlogPostSchema
    model = BlogPost

    # No need to define get_initial_state - it's automatic!
    # Just pass pk in the view: BlogPostEditor(request, pk=123)

    def toggle_published(self):
        """Toggle the published status."""
        obj = self.get_object(self.state.id)
        obj.published = not obj.published
        obj.save()
        self.refresh()  # Reload from database
        self.success("Post updated!")

    def save_content(self):
        """Save the current content."""
        obj = self.get_object(self.state.id)
        obj.title = self.state.title
        obj.content = self.state.content
        obj.save()
        self.success("Changes saved!")

Usage in view:

def edit_post(request, post_id):
    editor = BlogPostEditor(request=request, pk=post_id)
    return render(request, 'edit_post.html', {'editor': editor})

Template:

<!-- templates/components/blog_post_editor.html -->
<div class="editor">
    <input type="text" x-model="title" placeholder="Post title">

    <textarea x-model="content" rows="10"></textarea>

    <div class="actions">
        <button @click="call('save_content')">Save Draft</button>
        <button @click="call('toggle_published')">
            <span x-text="published ? 'Unpublish' : 'Publish'"></span>
        </button>
    </div>

    <!-- Messages -->
    <template x-for="msg in messages">
        <div :class="'alert-' + msg.level" x-text="msg.text"></div>
    </template>
</div>

CrudNitroComponent (Full CRUD)

Use when: You need a list view with create, read, update, delete operations.

Best for: Admin panels, data tables, list management.

Features:

  • Pre-built create_item(), delete_item(), start_edit(), save_edit(), cancel_edit() methods
  • Built-in create_buffer and edit_buffer for form handling
  • Automatic inline editing support
from typing import Optional
from pydantic import BaseModel, Field
from nitro.base import CrudNitroComponent
from nitro.registry import register_component


# Schema for a single task
class TaskSchema(BaseModel):
    id: int
    title: str
    completed: bool = False

    class Config:
        from_attributes = True


# Schema for creating/editing tasks (no id required)
class TaskFormSchema(BaseModel):
    title: str = ""
    completed: bool = False


# State schema for the component
class TaskListState(BaseModel):
    tasks: list[TaskSchema] = []
    create_buffer: TaskFormSchema = Field(default_factory=TaskFormSchema)
    edit_buffer: Optional[TaskFormSchema] = None
    editing_id: Optional[int] = None


@register_component
class TaskList(CrudNitroComponent[TaskListState]):
    template_name = "components/task_list.html"
    state_class = TaskListState
    model = Task  # Your Django model

    def get_initial_state(self, **kwargs):
        return TaskListState(
            tasks=[TaskSchema.model_validate(t) for t in Task.objects.all()]
        )

    def refresh(self):
        """Reload tasks from database."""
        self.state.tasks = [
            TaskSchema.model_validate(t)
            for t in Task.objects.all().order_by('-id')
        ]

    def toggle_completed(self, id: int):
        """Custom action to toggle task completion."""
        task = Task.objects.get(id=id)
        task.completed = not task.completed
        task.save()
        self.refresh()

    # create_item() - already implemented ✅
    # delete_item(id) - already implemented ✅
    # start_edit(id) - already implemented ✅
    # save_edit() - already implemented ✅
    # cancel_edit() - already implemented ✅

Template:

<!-- templates/components/task_list.html -->
<div class="task-list">
    <!-- Create new task -->
    <div class="create-form">
        <input
            type="text"
            x-model="create_buffer.title"
            placeholder="New task..."
            @keyup.enter="call('create_item')"
        >
        <button @click="call('create_item')">Add</button>
    </div>

    <!-- Task list -->
    <ul>
        <template x-for="task in tasks" :key="task.id">
            <li>
                <!-- Normal view -->
                <template x-if="editing_id !== task.id">
                    <div class="task-item">
                        <input
                            type="checkbox"
                            :checked="task.completed"
                            @click="call('toggle_completed', {id: task.id})"
                        >
                        <span x-text="task.title"></span>
                        <button @click="call('start_edit', {id: task.id})">Edit</button>
                        <button @click="call('delete_item', {id: task.id})">Delete</button>
                    </div>
                </template>

                <!-- Edit view -->
                <template x-if="editing_id === task.id && edit_buffer">
                    <div class="task-edit">
                        <input type="text" x-model="edit_buffer.title">
                        <button @click="call('save_edit')">Save</button>
                        <button @click="call('cancel_edit')">Cancel</button>
                    </div>
                </template>
            </li>
        </template>
    </ul>

    <!-- Messages -->
    <template x-for="msg in messages">
        <div :class="'alert-' + msg.level" x-text="msg.text"></div>
    </template>
</div>

BaseListComponent (Pagination + Search + Filters)

Use when: You need a list view with pagination, search, and filters.

Best for: Admin panels, data tables, dashboards, any paginated list.

Features:

  • Pre-built pagination with Django Paginator
  • Full-text search across configurable fields
  • Dynamic filtering
  • All CRUD operations (inherited from CrudNitroComponent)
  • Navigation methods: next_page(), previous_page(), go_to_page(), set_per_page()
  • Search and filter methods: search_items(), set_filters(), clear_filters()
  • Rich metadata: total_count, showing_start, showing_end for UX
from pydantic import BaseModel
from nitro.list import BaseListComponent, BaseListState
from nitro.registry import register_component
from myapp.models import Company


# Schema for a single company
class CompanySchema(BaseModel):
    id: int
    name: str
    email: str
    phone: str
    is_active: bool

    class Config:
        from_attributes = True


# Schema for creating/editing (no id)
class CompanyFormSchema(BaseModel):
    name: str = ""
    email: str = ""
    phone: str = ""


# State schema for the list
class CompanyListState(BaseListState):
    items: list[CompanySchema] = []
    # search, page, per_page, filters, etc. inherited from BaseListState

    # IMPORTANT: Must specify buffer types explicitly
    # (BaseListState uses Any, which causes inference issues)
    create_buffer: CompanyFormSchema = Field(default_factory=CompanyFormSchema)
    edit_buffer: Optional[CompanyFormSchema] = None


@register_component
class CompanyList(BaseListComponent[CompanyListState]):
    template_name = "components/company_list.html"
    state_class = CompanyListState
    model = Company

    # Configure search and pagination
    search_fields = ['name', 'email', 'phone']
    per_page = 25
    order_by = '-created_at'

    # All these methods are pre-built:
    # - Pagination: next_page(), previous_page(), go_to_page(), set_per_page()
    # - Search: search_items(search)
    # - Filters: set_filters(**filters), clear_filters()
    # - CRUD: create_item(), delete_item(), start_edit(), save_edit(), cancel_edit()

Template:

<!-- templates/components/company_list.html -->
<div class="company-list">

    <!-- Search bar -->
    <div class="search-bar">
        <input
            type="text"
            x-model="search"
            @input.debounce.300ms="call('search_items', {search: $el.value})"
            placeholder="Search companies..."
        >
        <button @click="call('clear_filters')">Clear</button>
    </div>

    <!-- Results info -->
    <div class="results-info" x-show="total_count > 0">
        Showing <strong x-text="showing_start"></strong>
        - <strong x-text="showing_end"></strong>
        of <strong x-text="total_count"></strong> results
    </div>

    <!-- Items table -->
    <table>
        <thead>
            <tr>
                <th>Name</th>
                <th>Email</th>
                <th>Phone</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
            <template x-for="company in items" :key="company.id">
                <tr>
                    <td x-text="company.name"></td>
                    <td x-text="company.email"></td>
                    <td x-text="company.phone"></td>
                    <td>
                        <button @click="call('start_edit', {id: company.id})">Edit</button>
                        <button @click="call('delete_item', {id: company.id})">Delete</button>
                    </td>
                </tr>
            </template>
        </tbody>
    </table>

    <!-- Pagination -->
    <div class="pagination">
        <button
            @click="call('previous_page')"
            :disabled="!has_previous || isLoading"
        >
            Previous
        </button>

        <span>
            Page <strong x-text="page"></strong> of <strong x-text="num_pages"></strong>
        </span>

        <button
            @click="call('next_page')"
            :disabled="!has_next || isLoading"
        >
            Next
        </button>

        <!-- Items per page selector -->
        <select
            x-model="per_page"
            @change="call('set_per_page', {per_page: parseInt($el.value)})"
        >
            <option value="10">10</option>
            <option value="20">20</option>
            <option value="50">50</option>
            <option value="100">100</option>
        </select>
    </div>

</div>

Advanced: Custom Filtering

Override get_base_queryset() for custom logic:

class CompanyList(BaseListComponent[CompanyListState]):
    def get_base_queryset(self, search='', filters=None):
        # Only show companies owned by current user
        qs = self.model.objects.filter(owner=self.request.user)

        # Apply standard search
        if search:
            qs = self.apply_search(qs, search)

        # Apply filters
        if filters:
            qs = self.apply_filters(qs, filters)

        # Custom ordering
        return qs.select_related('owner').order_by(self.order_by)

Usage in view:

def company_list_page(request):
    component = CompanyList(request=request)
    return render(request, 'company_list_page.html', {'companies': component})

Security Mixins & Authentication

Django Nitro v0.3.0+ provides powerful security mixins and authentication helpers for common authorization patterns. These mixins help you implement ownership-based access control, multi-tenant data isolation, and custom permissions with minimal code.

📚 Full Documentation: For complete guides and examples, visit the Security Documentation

Request User Helpers

Every NitroComponent has built-in properties and methods for working with authenticated users:

@register_component
class MyComponent(NitroComponent[MyState]):
    def my_action(self):
        # ✅ Shortcut to request.user (returns None if not authenticated)
        user = self.current_user

        # ✅ Check if user is authenticated
        if self.is_authenticated:
            user.profile.increment_action_count()

        # ✅ Enforce authentication requirement
        if not self.require_auth("You must be logged in"):
            return  # Stops execution and shows error message

        # Continue with authenticated user...

Properties:

  • current_user - Returns request.user if authenticated, otherwise None
  • is_authenticated - Returns True if user is authenticated

Methods:

  • require_auth(message="Authentication required") - Enforces auth, shows error if not authenticated

OwnershipMixin

Use when: You need to filter data to show only items owned by the current user.

Best for: User dashboards, personal data management, user-owned resources.

from nitro.security import OwnershipMixin
from nitro.list import BaseListComponent

@register_component
class MyDocuments(OwnershipMixin, BaseListComponent[DocumentListState]):
    model = Document
    owner_field = 'user'  # Field name linking to user (default: 'user')

    # Automatically filters queryset to current user's documents only

How it works:

  • Automatically adds a filter: queryset.filter(user=request.user)
  • Returns empty queryset if user is not authenticated
  • Override owner_field to customize the field name (e.g., 'author', 'created_by')

Example:

class MyTasksState(BaseListState):
    items: list[TaskSchema] = []

@register_component
class MyTasks(OwnershipMixin, BaseListComponent[MyTasksState]):
    model = Task
    owner_field = 'owner'
    search_fields = ['title', 'description']

    # Users can only see/edit their own tasks

TenantScopedMixin

Use when: Building multi-tenant SaaS applications where data must be isolated by organization/tenant.

Best for: SaaS apps, team workspaces, organization-based access control.

from nitro.security import TenantScopedMixin
from nitro.list import BaseListComponent

@register_component
class CompanyProjects(TenantScopedMixin, BaseListComponent[ProjectListState]):
    model = Project
    tenant_field = 'organization'  # Field linking to tenant (default: 'organization')

    def get_user_tenant(self):
        """Override to return current user's tenant/organization."""
        return self.request.user.current_organization

    # Automatically filters: queryset.filter(organization=current_organization)

Required: You must override get_user_tenant() to return the current user's tenant.

Example with profile-based tenancy:

@register_component
class TeamDocuments(TenantScopedMixin, BaseListComponent[DocumentListState]):
    model = Document
    tenant_field = 'team'

    def get_user_tenant(self):
        # Get tenant from user profile
        if hasattr(self.request.user, 'profile'):
            return self.request.user.profile.current_team
        return None

PermissionMixin

Use when: You need custom permission logic beyond Django's built-in permissions.

Best for: Complex authorization rules, role-based access control (RBAC), custom business logic.

from nitro.security import PermissionMixin
from nitro.base import CrudNitroComponent

@register_component
class AdminPanel(PermissionMixin, CrudNitroComponent[AdminState]):
    model = Settings

    def check_permission(self, action: str) -> bool:
        """Override with custom permission logic."""
        user = self.current_user

        if not user or not user.is_authenticated:
            return False

        # Custom role-based logic
        if action == 'delete':
            return user.is_superuser

        if action in ['create', 'edit']:
            return user.has_perm('settings.change_settings')

        return True  # Allow read by default

    def delete_item(self, id: int):
        # Enforce permission before deletion
        if not self.enforce_permission('delete', "Only admins can delete"):
            return

        super().delete_item(id)

Methods:

  • check_permission(action: str) -> bool - Override with your logic
  • enforce_permission(action: str, error_message: str = None) -> bool - Check and show error if denied

Example with subscription-based permissions:

class ProjectManager(PermissionMixin, CrudNitroComponent[ProjectState]):
    def check_permission(self, action: str) -> bool:
        user = self.current_user
        if not user:
            return False

        # Check subscription tier
        if action == 'create':
            if user.subscription_tier == 'free':
                # Check project count limit
                return user.projects.count() < 3
            return True  # Paid users unlimited

        return True

    def create_item(self):
        if not self.enforce_permission('create',
            "Free tier limited to 3 projects. Upgrade to create more."):
            return

        super().create_item()

Combining Mixins

You can combine multiple security mixins for powerful authorization:

@register_component
class TeamDocuments(
    OwnershipMixin,      # Filter by current user
    TenantScopedMixin,   # Filter by user's organization
    PermissionMixin,     # Custom permission logic
    BaseListComponent[DocumentListState]
):
    model = Document
    owner_field = 'created_by'
    tenant_field = 'organization'

    def get_user_tenant(self):
        return self.request.user.profile.organization

    def check_permission(self, action: str) -> bool:
        user = self.current_user
        if not user:
            return False

        # Managers can do everything
        if user.role == 'manager':
            return True

        # Members can read and create, but not delete
        if action == 'delete':
            return False

        return True

MRO (Method Resolution Order) matters:

  • Mixins are applied left to right
  • OwnershipMixin filters first, then TenantScopedMixin, then your component
  • Both filters are combined (AND logic)

State Management

How State Works

  1. Server-Side (Python): State is defined as a Pydantic model
  2. Rendered to HTML: State is embedded in the template as JSON
  3. Client-Side (Alpine): State becomes reactive Alpine data
  4. On Action: State is sent back to server, processed, and returned
  5. Auto-Sync: Alpine updates the UI reactively

State Schema Best Practices

from pydantic import BaseModel, Field, validator
from typing import Optional


class MyComponentState(BaseModel):
    # Use type hints for validation
    count: int = 0
    email: str = ""

    # Use Optional for nullable fields
    selected_id: Optional[int] = None

    # Use Field for defaults and validation
    items: list[str] = Field(default_factory=list)

    # Custom validation
    @validator('email')
    def email_must_be_valid(cls, v):
        if v and '@' not in v:
            raise ValueError('Invalid email')
        return v

    class Config:
        # Enable ORM mode for Django models
        from_attributes = True

Accessing State in Templates

<!-- Direct access to state properties -->
<div x-text="count"></div>
<div x-text="email"></div>
<div x-text="selected_id"></div>

<!-- Loop through arrays -->
<template x-for="item in items" :key="item">
    <div x-text="item"></div>
</template>

<!-- Conditional rendering -->
<div x-show="count > 0">Count is positive</div>
<div x-show="selected_id !== null">Something is selected</div>

Actions & Methods

Defining Actions

Any public method (not starting with _) on your component can be called from the template.

class MyComponent(NitroComponent[MyState]):
    # ✅ Can be called from template
    def increment(self):
        self.state.count += 1

    # ✅ Can accept parameters
    def add(self, amount: int):
        self.state.count += amount

    # ✅ Can use request object
    def save_for_user(self):
        if self.request.user.is_authenticated:
            # save logic
            pass

    # ❌ Cannot be called (starts with _)
    def _internal_helper(self):
        pass

Calling Actions from Templates

<!-- Simple call -->
<button @click="call('increment')">+1</button>

<!-- With parameters -->
<button @click="call('add', {amount: 5})">+5</button>

<!-- With debouncing -->
<input
    x-model="search"
    @input.debounce.300ms="call('search')"
>

<!-- On form submit -->
<form @submit.prevent="call('submit_form')">
    <button type="submit">Submit</button>
</form>

Template Integration

Available Variables

Every Nitro component template has access to:

<!-- State properties (direct access) -->
<div x-text="count"></div>
<div x-text="email"></div>

<!-- Internal properties -->
<div x-show="isLoading">Loading...</div>

<!-- Errors (per-field validation errors) -->
<span x-show="errors.email" x-text="errors.email"></span>

<!-- Messages (success/error notifications) -->
<template x-for="msg in messages">
    <div x-text="msg.text"></div>
</template>

Alpine Directives Cheatsheet

<!-- Text content -->
<div x-text="count"></div>

<!-- HTML content -->
<div x-html="content"></div>

<!-- Attributes -->
<button :disabled="isLoading">Click</button>
<input :class="{'error': errors.email}">

<!-- Show/Hide (keeps in DOM) -->
<div x-show="count > 0">Visible when count > 0</div>

<!-- If/Else (removes from DOM) -->
<template x-if="count > 0">
    <div>Count is positive</div>
</template>

<!-- Loops -->
<template x-for="item in items" :key="item.id">
    <div x-text="item.name"></div>
</template>

<!-- Two-way binding -->
<input x-model="email" type="email">

<!-- Events -->
<button @click="call('increment')">Click</button>
<input @keyup.enter="call('submit')">
<input @input.debounce.500ms="call('search')">

Security & Integrity

Django Nitro provides multiple layers of security to protect your application.

1. CSRF Protection

All Nitro requests automatically include Django's CSRF token. The JavaScript layer:

  • Reads the CSRF token from cookies
  • Includes it in every request header (X-CSRFToken)
  • Works with both JSON and FormData requests

No configuration needed - it just works with Django's standard CSRF middleware.

2. Integrity Verification

Nitro automatically protects sensitive fields from client-side tampering using HMAC-based signatures.

ModelNitroComponent automatically secures:

  • id field
  • Any field ending with _id (foreign keys)
class BlogPostEditor(ModelNitroComponent[BlogPostSchema]):
    model = BlogPost
    # Automatically secured: id, author_id

Custom Secure Fields:

class PricingComponent(NitroComponent[PricingState]):
    secure_fields = ['price', 'discount', 'currency']
    # These fields cannot be modified client-side

How It Works:

  1. On render, an integrity token is computed using Django's Signer (HMAC signature)
  2. Token is sent with every action
  3. Server verifies the token matches the current secure field values
  4. If verification fails → 403 Forbidden with error message

Client sees:

{
  "state": {"id": 123, "price": 99.99},
  "integrity": "abc123def456..."
}

If the user modifies price in browser DevTools and tries to send it back, the integrity check fails and the user sees:

⚠️ Security: Data has been tampered with.

3. Developer Responsibilities

While Nitro provides security foundations, you are responsible for:

✅ Authentication & Authorization

from django.core.exceptions import PermissionDenied

class DocumentEditor(ModelNitroComponent[DocumentSchema]):
    def delete_document(self):
        # CHECK: Is user authenticated?
        if not self.request.user.is_authenticated:
            raise PermissionDenied("Authentication required")

        # CHECK: Does user own this document?
        doc = self.get_object(self.state.id)
        if doc.owner != self.request.user:
            raise PermissionDenied("You don't own this document")

        # CHECK: Does user have permission?
        if not self.request.user.has_perm('documents.delete_document'):
            raise PermissionDenied("Missing delete permission")

        doc.delete()

✅ Input Validation

class UserProfileEditor(NitroComponent[ProfileState]):
    def update_profile(self):
        # Validate on server-side (never trust client data)
        if len(self.state.bio) > 500:
            self.error("Bio too long (max 500 characters)")
            return

        if not self.state.email or '@' not in self.state.email:
            self.error("Invalid email address")
            return

        # Additional validation
        if contains_profanity(self.state.bio):
            self.error("Bio contains inappropriate content")
            return

        # Save after validation
        profile = self.request.user.profile
        profile.bio = self.state.bio
        profile.save()

✅ Rate Limiting

from django.core.cache import cache
from django.core.exceptions import PermissionDenied

class SearchComponent(NitroComponent[SearchState]):
    def search(self):
        # Simple rate limiting example
        user_id = self.request.user.id or self.request.META.get('REMOTE_ADDR')
        cache_key = f"search_rate_{user_id}"

        request_count = cache.get(cache_key, 0)
        if request_count > 10:  # 10 searches per minute
            raise PermissionDenied("Too many searches. Please wait.")

        cache.set(cache_key, request_count + 1, 60)  # 60 seconds

        # Perform search...

✅ File Upload Security

class DocumentUploader(CrudNitroComponent[DocumentState]):
    def upload_file(self, uploaded_file=None):
        if not uploaded_file:
            self.error("No file provided")
            return

        # VALIDATE: File extension
        allowed = ['.pdf', '.docx', '.txt']
        ext = os.path.splitext(uploaded_file.name)[1].lower()
        if ext not in allowed:
            self.error(f"Invalid file type: {ext}")
            return

        # VALIDATE: File size
        max_size = 5 * 1024 * 1024  # 5MB
        if uploaded_file.size > max_size:
            self.error("File too large (max 5MB)")
            return

        # VALIDATE: Content type (not just extension)
        import magic  # python-magic library
        file_type = magic.from_buffer(uploaded_file.read(1024), mime=True)
        uploaded_file.seek(0)  # Reset file pointer

        allowed_types = ['application/pdf', 'text/plain']
        if file_type not in allowed_types:
            self.error(f"Invalid file content type: {file_type}")
            return

        # SANITIZE: Generate safe filename
        import uuid
        safe_filename = f"{uuid.uuid4()}{ext}"

        # Save with sanitized name
        doc = Document.objects.create(
            file=uploaded_file,
            original_name=uploaded_file.name[:100],  # Limit length
            owner=self.request.user
        )

✅ SQL Injection Prevention

Use Django ORM (never raw SQL with user input):

# ✅ SAFE - Django ORM parameterizes queries
def search(self):
    query = self.state.search_query
    results = Product.objects.filter(name__icontains=query)

# ❌ DANGEROUS - Never do this!
def search_raw(self):
    query = self.state.search_query
    cursor.execute(f"SELECT * FROM products WHERE name LIKE '%{query}%'")

✅ XSS Prevention

Django templates auto-escape by default, but be careful with:

# ✅ SAFE - Django auto-escapes in templates
# <div x-text="user_input"></div>

# ⚠️ BE CAREFUL with x-html
# <div x-html="user_input"></div>  # Can inject HTML/JS

# If you must use x-html, sanitize first:
import bleach

def save_content(self):
    # Only allow safe HTML tags
    self.state.content = bleach.clean(
        self.state.content,
        tags=['p', 'b', 'i', 'u', 'a'],
        attributes={'a': ['href']}
    )

Security Checklist

Before deploying your Nitro components:

  • All actions check request.user.is_authenticated if needed
  • Permission checks use has_perm() or custom authorization logic
  • File uploads validate type, size, and content
  • User input is validated server-side (never trust client)
  • Sensitive operations are rate-limited
  • Database queries use ORM (no raw SQL with user input)
  • secure_fields includes all IDs and sensitive data
  • Error messages don't leak sensitive information
  • DEBUG = False in production
  • ALLOWED_HOSTS is properly configured

Messages & Notifications

Adding Messages

Django Nitro provides four message levels for user feedback:

class MyComponent(NitroComponent[MyState]):
    def save(self):
        try:
            # ... save logic ...
            self.success("Item saved successfully!")  # Green toast
        except ValidationError as e:
            self.error(f"Validation failed: {str(e)}")  # Red toast
            logger.exception("Save failed")

    def export_data(self):
        self.info("Export started. You'll receive an email when complete.")  # Blue toast

    def check_quota(self):
        if self.get_usage() > 80:
            self.warning("You've used 80% of your quota!")  # Yellow/orange toast

Available methods:

  • self.success(message) - Green toast for successful operations
  • self.error(message) - Red toast for errors and failures
  • self.info(message) - Blue toast for informational messages
  • self.warning(message) - Yellow/orange toast for warnings

Toast Notifications (v0.4.0+)

Django Nitro includes a professional toast notification system that works out of the box, with support for custom toast libraries.

Native Toasts (No Dependencies)

By default, Nitro shows beautiful native toasts without any external dependencies:

# settings.py (optional - these are the defaults)
NITRO = {
    'TOAST_ENABLED': True,
    'TOAST_POSITION': 'top-right',  # top-right, top-left, top-center, bottom-right, bottom-left, bottom-center
    'TOAST_DURATION': 3000,  # milliseconds (3 seconds)
    'TOAST_STYLE': 'default',  # default, minimal, bordered
}

Features:

  • 6 positions (top-right, top-left, top-center, bottom-right, bottom-left, bottom-center)
  • 3 styles (default, minimal, bordered)
  • 4 levels (success, error, warning, info)
  • Auto-dismiss with configurable duration
  • Manual close button
  • Smooth animations
  • Responsive design

Per-Component Configuration

Override global settings for specific components:

@register_component
class CriticalAlert(NitroComponent[AlertState]):
    # Override toast settings for this component
    toast_enabled = True
    toast_position = 'top-center'
    toast_duration = 5000  # Show for 5 seconds
    toast_style = 'bordered'

    def alert_user(self):
        self.error("Critical: Server backup failed!")  # Uses component-specific config

Custom Toast Libraries

Integrate your favorite toast library (SweetAlert2, Toastify, Notyf, etc.) by defining a custom adapter:

<!-- In your base template, before nitro.js -->
<script>
// Example: SweetAlert2
window.NitroToastAdapter = {
    show: function(message, level, config) {
        Swal.fire({
            icon: level,
            title: message,
            toast: true,
            position: config.position || 'top-end',
            timer: config.duration || 3000,
            showConfirmButton: false
        });
    }
};
</script>

<!-- Or use Toastify -->
<script>
window.NitroToastAdapter = {
    show: function(message, level, config) {
        Toastify({
            text: message,
            className: level,
            duration: config.duration || 3000,
            gravity: config.position.includes('top') ? 'top' : 'bottom',
            position: config.position.includes('right') ? 'right' : 'left'
        }).showToast();
    }
};
</script>

See also: Toast Adapters Guide for complete examples.

Disabling Toasts

Disable toasts globally or per-component:

# settings.py - Disable all toasts
NITRO = {
    'TOAST_ENABLED': False,
}

# Or per-component
@register_component
class SilentComponent(NitroComponent[State]):
    toast_enabled = False  # No toasts for this component

Displaying Messages in Templates

If you prefer manual message handling over toasts:

<!-- Basic -->
<template x-for="msg in messages" :key="msg.text">
    <div x-text="msg.text"></div>
</template>

<!-- With styling based on level -->
<template x-for="msg in messages">
    <div
        :class="{
            'alert-success': msg.level === 'success',
            'alert-error': msg.level === 'error',
            'alert-warning': msg.level === 'warning',
            'alert-info': msg.level === 'info'
        }"
        x-text="msg.text"
    ></div>
</template>

<!-- Auto-dismiss with timeout -->
<template x-for="(msg, index) in messages" :key="index">
    <div
        x-data="{show: true}"
        x-init="setTimeout(() => show = false, 3000)"
        x-show="show"
        x-transition
        x-text="msg.text"
    ></div>
</template>

Events & Inter-Component Communication (v0.4.0+)

Django Nitro provides a powerful event system for communication between components and custom integrations.

Emitting Custom Events

Use emit() to send custom events from your component:

@register_component
class ShoppingCart(NitroComponent[CartState]):
    def add_to_cart(self, product_id: int):
        # Add product to cart
        self.state.items.append(product_id)

        # Emit custom event that other components can listen to
        self.emit('cart-updated', {
            'item_count': len(self.state.items),
            'product_id': product_id
        })

        self.success("Item added to cart")

Event name conventions:

  • Events are automatically prefixed with nitro: if not already
  • emit('cart-updated') → dispatches nitro:cart-updated
  • emit('nitro:custom') → dispatches nitro:custom (no double prefix)

Refreshing Other Components

Trigger a refresh in another component using the refresh_component() helper:

@register_component
class ProductEditor(ModelNitroComponent[ProductSchema]):
    def save_product(self):
        obj = self.get_object(self.state.id)
        obj.name = self.state.name
        obj.price = self.state.price
        obj.save()

        # Tell the ProductList component to refresh
        self.refresh_component('ProductList')

        self.success("Product saved!")

This emits a nitro:refresh-productlist event that your components can listen to.

Listening to Events (JavaScript)

Listen to Nitro events in your templates or custom JavaScript:

<script>
// Listen for cart updates
window.addEventListener('nitro:cart-updated', (event) => {
    console.log('Cart updated:', event.detail);
    // event.detail contains: { component: 'ShoppingCart', item_count: 3, product_id: 42 }

    // Update badge count
    document.getElementById('cart-badge').textContent = event.detail.item_count;
});

// Listen for component refresh requests
window.addEventListener('nitro:refresh-productlist', (event) => {
    console.log('ProductList should refresh');
    // Trigger a refresh on your component if needed
});
</script>

Built-in DOM Events

Nitro automatically dispatches these events:

nitro:message - Each message/toast notification

window.addEventListener('nitro:message', (event) => {
    // event.detail: { component: 'MyComponent', level: 'success', text: 'Saved!' }
});

nitro:action-complete - Action succeeded

window.addEventListener('nitro:action-complete', (event) => {
    // event.detail: { component: 'MyComponent', action: 'save', state: {...} }
    console.log('Action completed:', event.detail.action);
});

nitro:error - Error occurred

window.addEventListener('nitro:error', (event) => {
    // event.detail: { component: 'MyComponent', action: 'save', error: '...', status: 500 }
    console.error('Nitro error:', event.detail.error);
});

Example: Multi-Component Workflow

# Product editor component
@register_component
class ProductEditor(ModelNitroComponent[ProductSchema]):
    def delete_product(self):
        obj = self.get_object(self.state.id)
        product_name = obj.name
        obj.delete()

        # Notify analytics component
        self.emit('product-deleted', {'product_name': product_name})

        # Refresh the product list
        self.refresh_component('ProductList')

        self.success(f"Deleted {product_name}")

# Analytics component
@register_component
class Analytics(NitroComponent[AnalyticsState]):
    def track_deletion(self, product_name: str):
        # Called via JavaScript event listener
        self.state.deleted_count += 1
        # Send to analytics service...
<!-- In your template -->
<script>
window.addEventListener('nitro:product-deleted', (event) => {
    // Track the deletion
    fetch('/api/analytics/track', {
        method: 'POST',
        body: JSON.stringify({
            event: 'product_deleted',
            product: event.detail.product_name
        })
    });
});
</script>

CLI Tools (v0.4.0+)

Component Scaffolding

Quickly generate new Nitro components with the startnitro management command:

# Basic component
python manage.py startnitro ComponentName --app myapp

# List component with pagination and search
python manage.py startnitro ProductList --app products --list

# Full CRUD component
python manage.py startnitro TaskManager --app tasks --crud

Arguments:

  • ComponentName - Name of the component (must start with uppercase)
  • --app - Django app where the component will be created (required)
  • --list - Generate a list component with pagination and search
  • --crud - Generate a CRUD component with create/edit/delete (implies --list)

What Gets Generated

The command creates:

  1. Component file: {app}/components/{component_name}.py

    • State schema (Pydantic model)
    • Component class with actions
    • Imports and boilerplate
  2. Template file: {app}/templates/components/{component_name}.html

    • AlpineJS bindings
    • Form inputs
    • Action buttons
  3. Package file: {app}/components/__init__.py (if missing)

Example: Generated CRUD Component

python manage.py startnitro CompanyList --app companies --crud

Creates companies/components/company_list.py:

from pydantic import BaseModel, ConfigDict, Field
from nitro import BaseListComponent, BaseListState, register_component

class CompanySchema(BaseModel):
    """Schema for Company item."""
    model_config = ConfigDict(from_attributes=True)

    id: int
    name: str = ""
    # TODO: Add more fields

class CompanyFormSchema(BaseModel):
    """Form schema for creating/editing Company."""
    name: str = ""
    # TODO: Add more fields

class CompanyListState(BaseListState):
    """State schema for CompanyList component."""
    items: list[CompanySchema] = []
    create_buffer: CompanyFormSchema = Field(default_factory=CompanyFormSchema)
    edit_buffer: CompanyFormSchema | None = None

@register_component
class CompanyList(BaseListComponent[CompanyListState]):
    """
    CompanyList component with pagination, search, and CRUD.

    Usage:
        {% nitro_component 'CompanyList' %}
    """
    template_name = "components/company_list.html"
    state_class = CompanyListState
    # model = Company  # TODO: Uncomment and import model

    search_fields = ['name']  # TODO: Adjust search fields
    per_page = 20
    order_by = '-id'

    # Pre-built methods: create_item(), delete_item(), search_items(), etc.

And companies/templates/components/company_list.html with a complete UI including search, pagination, and CRUD forms.

Next steps:

  1. Uncomment and import your Django model
  2. Add fields to the schemas
  3. Customize search fields and actions
  4. Use in templates: {% nitro_component 'CompanyList' %}

SEO-Friendly Template Tags (v0.4.0+)

For public-facing content that needs SEO (search engine optimization), Nitro provides special template tags that render static HTML for crawlers while maintaining Alpine.js reactivity.

{% nitro_scripts %} - Include Nitro Assets

Load Nitro's CSS and JavaScript files (includes toast styles):

{% load nitro_tags %}
<!DOCTYPE html>
<html>
<head>
    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>

    <!-- Loads nitro.css and nitro.js -->
    {% nitro_scripts %}
</head>
<body>
    {% block content %}{% endblock %}
</body>
</html>

Expands to:

<link rel="stylesheet" href="/static/nitro/nitro.css">
<script defer src="/static/nitro/nitro.js"></script>

{% nitro_text %} - SEO-Friendly Text Binding

Renders server-side text that's also reactive with Alpine.js:

{% load nitro_tags %}

<!-- Traditional Alpine (not SEO-friendly) -->
<h1 x-text="product.name"></h1>
<!-- Crawlers see: <h1 x-text="product.name"></h1> (no text content) -->

<!-- SEO-friendly Nitro tag -->
<h1>{% nitro_text 'product.name' %}</h1>
<!-- Crawlers see: <h1><span x-text="product.name">Gaming Laptop</span></h1> -->
<!-- Users see: Same thing, but reactive! -->

How it works:

  1. Server renders the actual value ("Gaming Laptop")
  2. Adds x-text binding for reactivity
  3. When state changes, Alpine updates the content
  4. Search engines see the static HTML

Usage:

<div class="product-card">
    <h2>{% nitro_text 'item.name' %}</h2>
    <p class="price">${% nitro_text 'item.price' %}</p>
    <p class="description">{% nitro_text 'item.description' %}</p>
</div>

{% nitro_for %} - SEO-Friendly Loops

Renders list items on the server for SEO, while keeping Alpine.js reactivity:

{% load nitro_tags %}

<!-- Traditional Alpine (not SEO-friendly) -->
<template x-for="product in products" :key="product.id">
    <div class="card">
        <h3 x-text="product.name"></h3>
    </div>
</template>
<!-- Crawlers see: Nothing (template tag is not rendered) -->

<!-- SEO-friendly Nitro tag -->
{% nitro_for 'products' as 'product' %}
    <div class="card">
        <h3>{% nitro_text 'product.name' %}</h3>
        <p>{% nitro_text 'product.description' %}</p>
    </div>
{% end_nitro_for %}
<!-- Crawlers see: All cards with actual content -->
<!-- Users see: Same thing, but reactive when products change! -->

How it works:

  1. Server renders all items (e.g., 50 products)
  2. Wraps them in a hidden div (x-show="false")
  3. Adds an Alpine <template x-for> for reactivity
  4. Search engines index the static content
  5. Alpine shows/updates the reactive template

Complete example:

{% load nitro_tags %}

<div class="property-list">
    <h1>Available Properties</h1>

    <!-- SEO-friendly list -->
    {% nitro_for 'properties' as 'property' %}
        <div class="property-card">
            <h2>{% nitro_text 'property.title' %}</h2>
            <p class="address">{% nitro_text 'property.address' %}</p>
            <p class="price">${% nitro_text 'property.price' %}</p>

            <button @click="call('view_details', {id: property.id})">
                View Details
            </button>
        </div>
    {% end_nitro_for %}
</div>

When to use SEO tags:

  • ✅ Public product listings
  • ✅ Blog posts
  • ✅ Property/real estate listings
  • ✅ Any content you want indexed by Google
  • ❌ Private dashboards (not crawled anyway)
  • ❌ Admin panels
  • ❌ Authenticated-only content

Performance Optimization (v0.4.0+)

Smart State Updates (Diffing)

For components with large lists (500+ items), enable smart updates to only send changes instead of the full list:

from pydantic import BaseModel
from nitro import BaseListComponent, register_component

class TaskSchema(BaseModel):
    id: int  # Required for diffing
    title: str
    completed: bool

@register_component
class TaskList(BaseListComponent[TaskListState]):
    # Enable smart updates
    smart_updates = True

    def toggle_task(self, id: int):
        # Only the changed task is sent to the client
        task = Task.objects.get(id=id)
        task.completed = not task.completed
        task.save()

        # Refresh state
        self.refresh()
        # Client receives: {added: [], removed: [], updated: [{id: 42, completed: true}]}
        # Instead of: {items: [500 tasks...]}

How it works:

  1. Nitro compares old state vs new state
  2. For lists with items that have an id field, it calculates:
    • added - New items
    • removed - Deleted items (by id)
    • updated - Modified items
  3. Client applies changes in-place (no full re-render)

Requirements:

  • Items must have an id field
  • Set smart_updates = True on component
  • List must be in state (e.g., items: list[TaskSchema])

Example response:

{
  "partial": true,
  "state": {
    "items": {
      "diff": {
        "added": [{"id": 101, "title": "New task", "completed": false}],
        "removed": [99],
        "updated": [{"id": 42, "title": "Updated title", "completed": true}]
      }
    }
  }
}

Performance benefits:

  • 500 items → ~50KB full state vs ~1KB diff
  • Faster network transfer
  • Faster client-side processing
  • Better UX for real-time updates

When to use:

  • ✅ Lists with 100+ items
  • ✅ Real-time collaborative editing
  • ✅ Live dashboards with frequent updates
  • ❌ Small lists (< 50 items) - overhead not worth it
  • ❌ Components without list state

File Uploads

Django Nitro supports file uploads using Django Ninja's built-in multipart/form-data handling.

Backend: Add File Upload Action

from django.core.files.uploadedfile import UploadedFile

@register_component
class DocumentManager(CrudNitroComponent[DocumentState]):
    template_name = "components/document_manager.html"
    state_class = DocumentState
    model = Document

    def upload_file(self, document_id: int, uploaded_file=None):
        """
        Handle file upload.

        Note: The uploaded_file parameter name must match exactly.
        Nitro automatically detects it and passes the file from the request.
        """
        # Validate file was provided
        if not uploaded_file:
            self.error("No file selected")
            return

        # Validate file type
        allowed_extensions = ['.pdf', '.docx', '.txt']
        file_ext = os.path.splitext(uploaded_file.name)[1].lower()
        if file_ext not in allowed_extensions:
            self.error(f"Invalid file type. Allowed: {', '.join(allowed_extensions)}")
            return

        # Validate file size (5MB max)
        max_size = 5 * 1024 * 1024  # 5MB in bytes
        if uploaded_file.size > max_size:
            self.error("File too large (max 5MB)")
            return

        # Save to database
        try:
            doc = self.model.objects.get(id=document_id)
            doc.file = uploaded_file
            doc.save()

            self.refresh()
            self.success(f"File '{uploaded_file.name}' uploaded successfully!")
        except Exception as e:
            logger.exception("File upload failed")
            self.error(f"Upload failed: {str(e)}")

Frontend: File Input

<!-- templates/components/document_manager.html -->
<template x-for="doc in documents" :key="doc.id">
    <div class="document-item">
        <span x-text="doc.name"></span>

        <!-- Show current file if exists -->
        <template x-if="doc.file_url">
            <a :href="doc.file_url" target="_blank">View File</a>
        </template>

        <!-- File upload input -->
        <label class="file-upload">
            <input
                type="file"
                accept=".pdf,.docx,.txt"
                class="hidden"
                @change="(e) => {
                    const file = e.target.files[0];
                    if (file) {
                        // Third parameter is the file
                        call('upload_file', {document_id: doc.id}, file);
                        e.target.value = '';  // Reset input
                    }
                }"
            >
            <span>Upload File</span>
        </label>
    </div>
</template>

Important Notes

  1. Parameter Name: The action method must have a parameter named exactly uploaded_file - Nitro automatically detects this and passes the file
  2. File Validation: Always validate file type and size server-side
  3. CSRF Protection: File uploads automatically include CSRF tokens
  4. Media Settings: Configure Django's MEDIA_URL and MEDIA_ROOT in settings.py
  5. FormData: Nitro automatically uses FormData when a file is provided, no configuration needed

Django Settings for File Uploads

# settings.py

# Media files (user uploads)
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
# urls.py
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    # ... your URLs
]

# Serve media files in development
if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Debugging

Enable Debug Mode

To see detailed console logs for development:

<!-- Add to your base template before nitro.js -->
<script>
    window.NITRO_DEBUG = true;
</script>
<script src="{% static 'nitro/nitro.js' %}"></script>

When enabled, you'll see:

  • Component initialization logs
  • Action calls with state and payload
  • Server responses
  • Message notifications

Important: Remove or set to false in production to avoid console spam.


Advanced Usage

Nested Components

You can render Nitro components inside other Nitro components:

# Parent component
@register_component
class PropertyDetail(ModelNitroComponent[PropertySchema]):
    template_name = "components/property_detail.html"
    # ...

# Child component
@register_component
class TenantList(CrudNitroComponent[TenantListState]):
    template_name = "components/tenant_list.html"
    # ...
<!-- templates/components/property_detail.html -->
<div class="property-detail">
    <h1 x-text="name"></h1>
    <p x-text="address"></p>

    <!-- Render child component -->
    {% load nitro %}
    {{ tenant_list.render }}
</div>
def property_detail(request, pk):
    property_comp = PropertyDetail(request=request, pk=pk)
    tenant_list = TenantList(request=request, property_id=pk)
    return render(request, 'property_detail.html', {
        'property': property_comp,
        'tenant_list': tenant_list
    })

Optimistic Updates

For better UX, update the UI immediately without waiting for server response:

class TaskList(CrudNitroComponent[TaskListState]):
    def toggle_completed(self, id: int):
        # Update state immediately (optimistic)
        for task in self.state.tasks:
            if task.id == id:
                task.completed = not task.completed
                break

        # Then update database
        try:
            task_obj = Task.objects.get(id=id)
            task_obj.completed = not task_obj.completed
            task_obj.save()
        except Exception as e:
            # If it fails, refresh to revert
            self.refresh()
            self.error("Failed to update task")

Custom Refresh Logic

Override refresh() for custom reload behavior:

class ProductList(CrudNitroComponent[ProductListState]):
    def refresh(self):
        """Custom refresh that preserves filters."""
        # Keep current search query
        query = self.state.search_query

        # Reload products
        qs = Product.objects.filter(name__icontains=query)
        self.state.products = [
            ProductSchema.model_validate(p) for p in qs
        ]

        # Don't clear buffers (unlike default refresh)
        # Users can keep typing while data refreshes

Permission Checks

Use Django's permission system in your actions:

from django.core.exceptions import PermissionDenied

class DocumentEditor(ModelNitroComponent[DocumentSchema]):
    def delete_document(self):
        if not self.request.user.has_perm('documents.delete_document'):
            raise PermissionDenied("You don't have permission to delete")

        obj = self.get_object(self.state.id)
        obj.delete()
        self.success("Document deleted")

Best Practices

1. Keep Actions Small and Focused

# ✅ Good
def increment(self):
    self.state.count += 1

def save_and_notify(self):
    self.save()
    send_notification(self.request.user)

# ❌ Avoid
def do_everything(self):
    self.state.count += 1
    self.save()
    send_email()
    log_analytics()
    # too much responsibility

2. Use Proper Validation

from pydantic import BaseModel, validator, EmailStr

class FormState(BaseModel):
    email: EmailStr  # Built-in email validation
    age: int

    @validator('age')
    def age_must_be_positive(cls, v):
        if v < 0:
            raise ValueError('Age cannot be negative')
        return v

3. Handle Errors Gracefully

def save(self):
    try:
        # ... save logic ...
        self.success("Saved!")
    except ValidationError as e:
        self.error("Please check your input")
    except Exception as e:
        logger.exception("Unexpected error")
        self.error("Something went wrong. Please try again.")

4. Optimize Database Queries

def get_initial_state(self, **kwargs):
    # ✅ Good - use select_related/prefetch_related
    properties = Property.objects.select_related('owner').prefetch_related('tenants')

    # ❌ Avoid - N+1 queries
    properties = Property.objects.all()
    for prop in properties:
        _ = prop.owner  # separate query each time

5. Use Debouncing for Search

<!-- Debounce search to avoid too many requests -->
<input
    x-model="search_query"
    @input.debounce.300ms="call('search')"
    placeholder="Search..."
>

6. Provide Loading States

<!-- Show loading indicator -->
<div x-show="isLoading" class="spinner">Loading...</div>

<!-- Disable buttons during loading -->
<button @click="call('save')" :disabled="isLoading">
    Save
</button>

Comparison to Alternatives

vs Django Unicorn

Feature Django Nitro Django Unicorn
Frontend Library AlpineJS (~15KB) Morphdom (~50KB)
State Validation Pydantic (strict typing) Django Forms/Models
Type Safety Full (Generic types) Partial
API Layer Django Ninja (fast) Custom
Learning Curve Low (if you know Alpine) Medium
Syntax @click="call('action')" unicorn:click="action"

vs HTMX

Feature Django Nitro HTMX
State Management Automatic (Pydantic) Manual (server-side)
Reactivity Full (Alpine) Partial (HTML swaps)
Complexity Medium Low
Use Case Complex SPAs Simple interactions

vs Vanilla Alpine + Django

Feature Django Nitro Alpine + Django
Backend Integration Built-in Manual API calls
State Sync Automatic Manual
Type Safety Yes (Pydantic) No
Security Built-in integrity Manual CSRF

Example Projects

This repository includes two complete example applications in the examples/ folder:

1. Counter Example (examples/counter/)

Simple beginner-friendly example demonstrating basic Nitro concepts:

  • ✅ Component state management
  • ✅ Action methods (increment, decrement, reset)
  • ✅ AlpineJS template bindings
  • ✅ Success messages

2. Property Manager (examples/property-manager/)

Comprehensive real-world example with advanced features:

  • ✅ Property CRUD with search and pagination
  • ✅ Tenant management (nested component)
  • ✅ Inline editing
  • ✅ Real-time validation
  • ✅ Success/error messages
  • ✅ File uploads (PDF documents for tenants)

Run an example:

git clone https://github.com/django-nitro/django-nitro.git
cd django-nitro/examples/counter  # or examples/property-manager
python -m venv env
source env/bin/activate  # Windows: env\Scripts\activate
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver

Visit http://localhost:8000


Troubleshooting

Component not updating?

Check:

  1. Is nitro.js loaded after Alpine?
  2. Are you calling call('action_name') correctly?
  3. Check browser console for errors
  4. Verify the API URL is /api/nitro/dispatch

"Component not found" error?

Make sure:

  1. Component is decorated with @register_component
  2. Component file is imported somewhere (e.g., in apps.py)

State not persisting?

  • State is not persisted between page loads
  • Use Django sessions or database for persistence
  • Component state resets on page refresh

Alpine errors like "Cannot read property"?

  • Use x-show="edit_buffer && edit_buffer.field" instead of just x-show="edit_buffer.field"
  • Alpine evaluates bindings even when elements are hidden

Contributing

Contributions are welcome! Please:

  1. Fork the repo
  2. Create a feature branch (git checkout -b feature/amazing)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing)
  5. Open a Pull Request

License

MIT License - see LICENSE file for details.


Credits


Support & Community


Built with ❤️ for the Django community

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_nitro-0.4.0.tar.gz (88.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_nitro-0.4.0-py3-none-any.whl (51.7 kB view details)

Uploaded Python 3

File details

Details for the file django_nitro-0.4.0.tar.gz.

File metadata

  • Download URL: django_nitro-0.4.0.tar.gz
  • Upload date:
  • Size: 88.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.2

File hashes

Hashes for django_nitro-0.4.0.tar.gz
Algorithm Hash digest
SHA256 bc832e31125950040fe044bcfaf16aec42e081394ac81149be06e0f47a32d046
MD5 7ec73c6439ad28422b7b78b04ada88e2
BLAKE2b-256 3bda7332d16beb31160e21353f32254092bc23b4b24b10625d4826063bcf297b

See more details on using hashes here.

File details

Details for the file django_nitro-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: django_nitro-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 51.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.2

File hashes

Hashes for django_nitro-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8b6d7e9c85c8190c10d9239ed9fb11b6f7ac1c846ccb9b3f07423f3b2599bb68
MD5 95491236d72972cece3cd13aaafa5716
BLAKE2b-256 367ac4ec57e9be20d37dd214b876d5b5064651c9c9dc74a5fef3cdab7e35e527

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page