Skip to content

nanodjango integration

nanodjango is a single-file Django framework for quick demos, prototypes, and small apps. Django-Bolt ships a nanodjango plugin that lets you run Bolt's Rust/Actix server inside a nanodjango app without manually wiring INSTALLED_APPS, BOLT_API, or import order -- just import BoltAPI from django_bolt.nanodjango and start defining routes.

Install

pip install "django-bolt[nanodjango]"

Or with uv:

uv add "django-bolt[nanodjango]"

This pulls in nanodjango as an extra and registers Django-Bolt as a nanodjango plugin automatically.

Quickstart

A complete single-file app, helloworld.py. With uv's inline script metadata, you can share it as a self-installing example:

# /// script
# dependencies = [
#     "django-bolt[nanodjango]",
# ]
# ///

from nanodjango import Django
from django_bolt.nanodjango import BoltAPI

app = Django()
bolt = BoltAPI()


# Django route (WSGI)
@app.route("/")
def home(request):
    return r"""<h1>Hello, django-bolt + nanodjango!</h1>
    Check out the autogenerated <a href="/docs/">API docs</a>.
    <br>Invoke the API directly:
    <p>Try <a href="/api/hello">/api/hello</a> or
       <a href="/api/greet?name=World">/api/greet?name=World</a></p>"""


# Bolt API routes

@bolt.get("/api/hello")
async def hello(request):
    return {"message": "hello from bolt"}


@bolt.get("/api/greet")
async def greet(name: str):
    return {"message": f"hello, {name}!"}


@bolt.post("/api/echo")
async def echo(msg: str):
    return {"you_sent": msg}


# Register the Django catch-all AFTER all bolt routes so they take priority.
# Unmatched requests (like /) are forwarded to Django's ASGI app.
bolt.mount_django(r"/")


if __name__ == "__main__":
    import sys

    # Imported after Django settings are configured (single-file style)
    from django.core.management import execute_from_command_line
    execute_from_command_line(sys.argv)

Run it with uv -- it creates a temporary environment, downloads dependencies, and executes the script:

uv run helloworld.py runbolt

Using the nanodjango CLI

Alternatively, use nanodjango's management CLI. In this case you don't need the if __name__ == "__main__" block:

nanodjango manage myapp.py runbolt --port 8000

What it does

Without this plugin, using Django-Bolt in a single-file nanodjango app requires manually configuring INSTALLED_APPS, BOLT_API, and getting the import order right (django-bolt must be imported after Django settings are configured).

The django_bolt.nanodjango plugin handles all of that:

  • Adds django_bolt to INSTALLED_APPS automatically
  • Configures BOLT_API (the setting that tells runbolt where to find your API instance) by detecting the variable name at decoration time
  • Subclasses django_bolt.BoltAPI so runbolt's autodiscovery accepts it
  • Registers as a nanodjango plugin via setuptools entry points (no config needed)
  • Supports nanodjango convert by detecting BoltAPI instances and route-decorated functions in the AST and moving them to api.py in the generated project

Running the server

Django-Bolt runs a Rust/Actix server, not Django's dev server. You have a few deployment patterns to choose from.

Single port (Django views + Bolt API together)

Use mount_django to serve everything on one port. Bolt routes are handled natively by Actix; all other requests are forwarded to Django's ASGI application through an in-process bridge.

from nanodjango import Django
from django_bolt.nanodjango import BoltAPI

app = Django()
api = BoltAPI()

@app.route("/")
def home(request):
    return "<h1>Hello from Django</h1>"

@api.get("/api/hello")
async def hello(request):
    return {"message": "hello from bolt"}

# Mount Django as the fallback. Must be called AFTER all BoltAPI routes
# are defined, since bolt routes take priority.
api.mount_django(r"/")

Because the file must be importable, avoid non-importable characters in the filename -- use myapp.py, not my-app.py.

Bolt only (no Django view fallback)

If you don't need Django views served over HTTP, skip mount_django:

nanodjango manage myapp.py runbolt --dev --port 8000

Note that if you have called api.mount_django("/path"), Django will still be served over HTTP under that prefix.

Separate ports (Django dev + Bolt on different ports)

Useful during development if you want separate logs / restarts for each server:

# Terminal 1 -- Django views
nanodjango run myapp.py --host localhost:8080
# or: nanodjango manage myapp.py runserver --host localhost:8080

# Terminal 2 -- Bolt API
nanodjango manage myapp.py runbolt --port 8001

Production mode

Run Django under uvicorn via nanodjango serve and Bolt via runbolt, then put a reverse proxy (nginx, Caddy, etc.) in front of both:

# Terminal 1 -- Django (uvicorn)
nanodjango serve myapp.py --host localhost:8080

# Terminal 2 -- Bolt API
nanodjango manage myapp.py runbolt --port 8001

# Then configure your reverse proxy to route public traffic to both.

runbolt options

Flag Default Description
--host 0.0.0.0 Host to bind to
--port 8000 Port to bind to
--processes 1 Number of worker processes
--dev off Auto-reload on file changes
--no-admin off Disable Django admin integration
--backlog 1024 Socket listen backlog
--keep-alive OS default HTTP keep-alive timeout (seconds)

Streaming responses (SSE)

For Server-Sent Events, use EventSourceResponse. It handles JSON encoding, SSE wire framing (data: ...\n\n), keep-alive pings, and skipping compression -- compression buffers stream chunks and breaks real-time delivery.

import asyncio
from collections.abc import AsyncIterable

from nanodjango import Django
from django_bolt.nanodjango import BoltAPI
from django_bolt.responses import EventSourceResponse

app = Django()
bolt = BoltAPI()


@bolt.get("/stream", response_class=EventSourceResponse)
async def stream() -> AsyncIterable[dict]:
    for i in range(5):
        yield {"message": f"message {i}"}
        await asyncio.sleep(1)

Yielded objects are JSON-serialized and wrapped in SSE framing automatically. See the Server-Sent Events guide for the explicit form, custom event types, IDs, retry intervals, and ping configuration.

If you need raw bytes / manual framing, the legacy StreamingResponse + @no_compress decorator combination still works -- but EventSourceResponse is the recommended path.

WebSockets

WebSocket endpoints work the same way as in a non-nanodjango Bolt app -- the plugin doesn't add anything special. Decorate with @bolt.websocket(path) and accept a WebSocket parameter:

from nanodjango import Django
from django_bolt import WebSocket, WebSocketDisconnect
from django_bolt.nanodjango import BoltAPI

app = Django()
bolt = BoltAPI()


@bolt.websocket("/ws/echo")
async def echo(websocket: WebSocket):
    await websocket.accept()
    try:
        while True:
            msg = await websocket.receive_text()
            await websocket.send_text(f"echo: {msg}")
    except WebSocketDisconnect:
        pass

A connecting client (browser):

const ws = new WebSocket(`ws://${location.host}/ws/echo`);
ws.onmessage = (e) => console.log(e.data);
ws.onopen = () => ws.send("hello");

Path parameters, JSON helpers, guards, and auth all work the same as documented in the WebSocket guide.

Constructing inside a factory

The plugin uses frame inspection at BoltAPI() construction time to figure out which module owns the instance, so it can set BOLT_API correctly. This works as long as you call BoltAPI() at module top level (the typical single-file pattern).

If you wrap construction in a factory or helper, frame inspection will pick up the wrong module and runbolt won't find your routes. Pass module=__name__ explicitly to bypass it:

def make_bolt():
    bolt = BoltAPI(module=__name__)  # or "myapp" / wherever the binding lives
    ...
    return bolt

bolt = make_bolt()

The plugin emits a warning to the django_bolt.nanodjango logger when auto-detection fails, so you'll see the issue at startup rather than getting a silent empty router.

Converting to a full project

When you outgrow the single-file format, nanodjango can scaffold a full Django project:

nanodjango convert myapp.py /path/to/project --name=myproject

The plugin's convert_build_app_api hook detects BoltAPI() assignments and @bolt.get/post/... decorated functions and moves them into api.py in the generated project.

Note

Due to how nanodjango is written, you will get some superfluous code referring to django-ninja calls in the generated project. Strip or adapt those references after the conversion.

Comparison: with and without the plugin

Without the plugin -- manual wiring:

import sys
from django.conf import settings

settings.configure(
    DEBUG=True,
    SECRET_KEY="change-me",
    ROOT_URLCONF=__name__,
    INSTALLED_APPS=[
        "django.contrib.contenttypes",
        "django.contrib.auth",
        "django_bolt",
    ],
    BOLT_API=["__main__:api"],
)

from django_bolt import BoltAPI  # must be after settings.configure()

api = BoltAPI()

@api.get("/hello")
async def hello(request):
    return {"message": "hello"}

if __name__ == "__main__":
    from django.core.management import execute_from_command_line
    execute_from_command_line(sys.argv)

With the plugin:

from nanodjango import Django
from django_bolt.nanodjango import BoltAPI

app = Django()
bolt = BoltAPI()

@bolt.get("/hello")
async def hello(request):
    return {"message": "hello"}