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

<!-- 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) -->
    <script src="{% static 'nitro/nitro.js' %}"></script>
</body>
</html>

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

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

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

Displaying Messages

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

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 Project

This repository includes a complete example app in the example/ folder:

Features:

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

Run the example:

git clone https://github.com/yourusername/django-nitro.git
cd django-nitro
python -m venv env
source env/bin/activate  # Windows: env\Scripts\activate
pip install -e ".[dev]"
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.1.0.tar.gz (46.8 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.1.0-py3-none-any.whl (25.5 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: django_nitro-0.1.0.tar.gz
  • Upload date:
  • Size: 46.8 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.1.0.tar.gz
Algorithm Hash digest
SHA256 59be1fd500eafb151b94c40d2287579c412a4f8f36d79734ae23df145b4d3eb9
MD5 4c70e3581860cdfb5a6b5ea41a51e6de
BLAKE2b-256 14fdec5b27aa7e2a9e9444b3d3c5503eea12f887540c7635051e235c8a621ab2

See more details on using hashes here.

File details

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

File metadata

  • Download URL: django_nitro-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 25.5 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.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2f45686c026376367f0e58b2a223cdec7d35be1af2ff43ee2a1eb73719327629
MD5 63087fa37021bd90ebfc2ec89f4151e7
BLAKE2b-256 b4d99a06eac4921a85e7bb0f111afe3e63875eda9f279ac211b1d5e3f52f9fe2

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