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.
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
- Quick Start
- Core Concepts
- Security Mixins & Authentication
- State Management
- Actions & Methods
- Template Integration
- Security & Integrity
- Messages & Notifications
- Events & Inter-Component Communication
- CLI Tools
- SEO-Friendly Template Tags
- Performance Optimization
- File Uploads
- Debugging
- Advanced Usage
- Best Practices
- Comparison to Alternatives
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
pkorid - 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_bufferandedit_bufferfor 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_endfor 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- Returnsrequest.userif authenticated, otherwiseNoneis_authenticated- ReturnsTrueif 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_fieldto 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 logicenforce_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
OwnershipMixinfilters first, thenTenantScopedMixin, then your component- Both filters are combined (AND logic)
State Management
How State Works
- Server-Side (Python): State is defined as a Pydantic model
- Rendered to HTML: State is embedded in the template as JSON
- Client-Side (Alpine): State becomes reactive Alpine data
- On Action: State is sent back to server, processed, and returned
- 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:
idfield- 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:
- On render, an integrity token is computed using Django's
Signer(HMAC signature) - Token is sent with every action
- Server verifies the token matches the current secure field values
- 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_authenticatedif 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_fieldsincludes all IDs and sensitive data - Error messages don't leak sensitive information
-
DEBUG = Falsein production -
ALLOWED_HOSTSis 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 operationsself.error(message)- Red toast for errors and failuresself.info(message)- Blue toast for informational messagesself.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')→ dispatchesnitro:cart-updatedemit('nitro:custom')→ dispatchesnitro: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:
-
Component file:
{app}/components/{component_name}.py- State schema (Pydantic model)
- Component class with actions
- Imports and boilerplate
-
Template file:
{app}/templates/components/{component_name}.html- AlpineJS bindings
- Form inputs
- Action buttons
-
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:
- Uncomment and import your Django model
- Add fields to the schemas
- Customize search fields and actions
- 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:
- Server renders the actual value ("Gaming Laptop")
- Adds
x-textbinding for reactivity - When state changes, Alpine updates the content
- 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:
- Server renders all items (e.g., 50 products)
- Wraps them in a hidden div (
x-show="false") - Adds an Alpine
<template x-for>for reactivity - Search engines index the static content
- 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:
- Nitro compares old state vs new state
- For lists with items that have an
idfield, it calculates:added- New itemsremoved- Deleted items (by id)updated- Modified items
- Client applies changes in-place (no full re-render)
Requirements:
- Items must have an
idfield - Set
smart_updates = Trueon 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
- Parameter Name: The action method must have a parameter named exactly
uploaded_file- Nitro automatically detects this and passes the file - File Validation: Always validate file type and size server-side
- CSRF Protection: File uploads automatically include CSRF tokens
- Media Settings: Configure Django's
MEDIA_URLandMEDIA_ROOTin settings.py - 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:
- Is
nitro.jsloaded after Alpine? - Are you calling
call('action_name')correctly? - Check browser console for errors
- Verify the API URL is
/api/nitro/dispatch
"Component not found" error?
Make sure:
- Component is decorated with
@register_component - 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 justx-show="edit_buffer.field" - Alpine evaluates bindings even when elements are hidden
Contributing
Contributions are welcome! Please:
- Fork the repo
- Create a feature branch (
git checkout -b feature/amazing) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing) - Open a Pull Request
License
MIT License - see LICENSE file for details.
Credits
- Inspired by Django Unicorn and Laravel Livewire
- Built with AlpineJS
- API powered by Django Ninja
- State validation by Pydantic
Support & Community
- 📖 Documentation
- 🐛 Report Issues
- 💬 Discussions
- ⭐ Star us on GitHub
Built with ❤️ for the Django community
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bc832e31125950040fe044bcfaf16aec42e081394ac81149be06e0f47a32d046
|
|
| MD5 |
7ec73c6439ad28422b7b78b04ada88e2
|
|
| BLAKE2b-256 |
3bda7332d16beb31160e21353f32254092bc23b4b24b10625d4826063bcf297b
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8b6d7e9c85c8190c10d9239ed9fb11b6f7ac1c846ccb9b3f07423f3b2599bb68
|
|
| MD5 |
95491236d72972cece3cd13aaafa5716
|
|
| BLAKE2b-256 |
367ac4ec57e9be20d37dd214b876d5b5064651c9c9dc74a5fef3cdab7e35e527
|