This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Django Ninja Quickstart

Our copy of the Django Ninja Quick Start, adapted for uv and the chat app in your locallibrary project.

In three short pages you’ll build a small chat API for the chat rooms in your locallibrary: people can browse rooms, filter and paginate them, read a room’s messages, sign in, and post a message that shows up live in the chat page. Along the way you’ll see most of what makes Django Ninja pleasant to use: type hints that validate input, schemas generated from your models, interactive docs, auth, and routers.

This guide assumes you already know Django: projects, apps, models and migrations. It won’t explain those; it only shows the Django Ninja parts.

Page What you’ll do
1. First API Install, create an API, write an endpoint, open the interactive docs
2. Models & Schemas Save chat rooms and messages to the database, then full CRUD for the Room model, plus pagination and filtering
3. Auth, Routers & Next Steps Split the API into a router, require login, let users post messages to a room

Before you start

We assume that you have completed preps 01-06 (our fork of the MDN Django Tutorial through Part 8) and all four parts of our Channels tutorial. You have your locallibrary repository: a uv project with manage.py, pyproject.toml, and uv.lock at its root, the locallibrary_config project package, the catalog app, and the asynchronous chat app. Every command in this guide is run from that root folder.

The chat app works, but it forgets everything: a message exists only while it travels through Redis to the browsers in the room. An API that lists rooms and messages needs them in the database. So before the Django Ninja parts, page 2 adds Room and Message models to the chat app, saves every message to the database, and shows a room’s history when you open it.

How this copy differs from the original

This is a copy of the official Django Ninja Quick Start, changed to match the way we have worked in our fork of the MDN Django Tutorial and our Channels tutorial:

  • You don’t create a new project or app. The API goes into your existing locallibrary repository. Everywhere the original says mysite, this copy says locallibrary_config, and the original’s events app is your chat app.
  • Chat rooms instead of events. The original builds an API for an Event model, and users register for events. This copy builds the same API for a Room model, and users post Messages to rooms. Each piece of the original has a counterpart here: EventFilter’s upcoming filter becomes RoomFilter’s recent filter, the 409 for an event that clashes with another becomes a 409 for a room name that’s taken, and the nested RegistrationOut schema becomes a nested MessageOut schema.
  • You add models to the chat app first. Page 2 starts with Room and Message models, saves messages from the WebSocket consumer, and shows a room’s saved messages when you open it. The original has nothing like this, because it starts from an empty app.
  • Posted messages go to the chat page. On page 3, a message posted through the API is also sent to everyone in the room over the channel layer, so it appears in the chat page right away.
  • Dependencies are managed with uv. pip install becomes uv add, and every command is run with uv run python ….
  • Edits to existing files are additions. The original shows complete urls.py contents because it assumes a new, empty project. Here you only add lines, so you keep what you built for catalog and chat.

This copy follows the Django Ninja documentation as of 2026-10-19 (Django Ninja 1.7.1), on Django 6.1, Channels 4.3, and Python 3.14. Once you finish, continue with our Django Ninja Tutorial.


The original Quick Start is Copyright (c) 2025 Vitaliy Kucheryaviy, and is used under its MIT license.

1 - Quick Start Part 1: First API

Install Django Ninja, create an API in locallibrary, write an endpoint, and open the interactive docs.

Install

Add Django Ninja to your project:

uv add django-ninja

Just like uv add "channels[daphne]" did in the Channels tutorial, this records the dependency in pyproject.toml, installs it into .venv, and pins the exact versions in uv.lock. Commit both files.

Django Ninja works with any Django project. The original Quick Start creates a new project and an events app at this point. You don’t: you already have a project, locallibrary_config, and the chat app plays the part of events for the rest of this guide.

Django Ninja itself doesn’t need to be in INSTALLED_APPS, but adding "ninja" lets Django’s runserver and collectstatic serve the interactive docs’ assets locally instead of from a CDN. Edit locallibrary_config/settings.py and add 'ninja' to the end of INSTALLED_APPS, after chat. Don’t remove anything that’s already there:

# locallibrary_config/settings.py
INSTALLED_APPS = [
    'daphne',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # Add our new application
    'catalog.apps.CatalogConfig', # This object was created for us in /catalog/apps.py
    'chat',
    'ninja',
]

Create the API

An API starts with a NinjaAPI instance. Create locallibrary_config/api.py next to urls.py:

# locallibrary_config/api.py
from ninja import NinjaAPI

api = NinjaAPI()


@api.get("/hello")
def hello(request, name: str = "world"):
    return {"message": f"Hello, {name}!"}

Then mount it in locallibrary_config/urls.py, like any other set of views. That file already includes catalog.urls and chat.urls. Add the API in the same style, at the end of the file:

# locallibrary_config/urls.py
# (after the existing blocks)
# Mount the Django Ninja API
from .api import api

urlpatterns += [
    path('api/', api.urls),
]

Leave the admin, catalog, chat, redirect, and static-files entries as they are.

Run the server:

uv run python manage.py runserver

This is the Daphne development server from the Channels tutorial. It serves your Django Ninja API the same way it serves your catalog pages. Redis doesn’t need to be running for this page; only the chat’s WebSockets use it.

Open http://127.0.0.1:8000/api/hello?name=Ninja πŸ”—:

{"message": "Hello, Ninja!"}

A few things happened with no extra code:

  • @api.get("/hello") registered a GET endpoint at /api/hello.
  • The request argument is a regular Django HttpRequest, so request.user, sessions and middleware all work as usual.
  • name: str = "world" became an optional query parameter with a default value.
  • The returned dict was serialized to JSON.

Type hints are validation

Every argument’s type hint says what the endpoint accepts. Add an endpoint with two required integers to locallibrary_config/api.py:

# locallibrary_config/api.py
@api.get("/add")
def add(request, a: int, b: int):
    return {"result": a + b}

/api/add?a=2&b=3 πŸ”— returns {"result": 5}, and a and b arrive in your function as real ints, not strings. Invalid input never reaches your code. Try /api/add?a=2&b=three πŸ”—:

{
    "detail": [
        {
            "type": "int_parsing",
            "loc": ["query", "b"],
            "msg": "Input should be a valid integer, unable to parse string as an integer"
        }
    ]
}

The response has status code 422, and the error points to exactly which parameter failed and why. The same works for float, bool, date, datetime, UUID, enums, and more. See Query Parameters for the full list.

Interactive docs

Now open http://127.0.0.1:8000/api/docs πŸ”—:

Swagger UI

Django Ninja generates an OpenAPI schema from your code, including parameter types, required fields and responses, and serves it with Swagger UI. Click an endpoint, then Try it out to call it from the browser. The raw schema is at /api/openapi.json, ready for client generators and API tools.

The docs are never out of date, because they are built from the same type hints that validate your requests. See OpenAPI & Interactive Docs to customize them.

Next

So far the API only returns hand-built dicts. Next, give your chat rooms and messages a home in the database, connect the API to them, and build a real CRUD API: Models & Schemas.

2 - Quick Start Part 2: Models & Schemas

Save chat rooms and messages to the database, then build a CRUD API for rooms with pagination and filtering.

On this page you’ll give your chat rooms and messages a home in the database, then turn the Room model into a complete CRUD API, and add pagination and filtering. Along the way you’ll see how schemas are generated from your models.

The models

Nothing Ninja-specific here, just regular Django models. The original Quick Start has a single Event model. Your chat needs two: a Room, and the Messages posted in it.

In Part 1 of the Channels tutorial, you deleted chat/models.py because the chat didn’t store anything. Create it again, with this code:

# chat/models.py
from django.conf import settings
from django.db import models


class Room(models.Model):
    name = models.CharField(max_length=50, unique=True)
    topic = models.CharField(max_length=200, blank=True, default="")
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name


class Message(models.Model):
    room = models.ForeignKey(Room, related_name="messages", on_delete=models.CASCADE)
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        related_name="chat_messages",
        on_delete=models.SET_NULL,
    )
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["created_at", "id"]

    def __str__(self):
        return f"{self.author or 'anonymous'} in {self.room}: {self.content[:50]}"
  • A room’s name is the name in its URL, like lobby in /chat/lobby/, so two rooms can’t share one: unique=True. It’s also part of the room’s Channels group name (chat_lobby), and group names must be shorter than 100 characters, so 50 leaves plenty of room.
  • topic is optional. blank=True lets forms and the admin leave it empty, and default="" gives new rooms an empty topic.
  • author is optional too, because anyone can chat without logging in: AuthMiddlewareStack in your asgi.py puts an anonymous user in the scope for them. If a user is deleted, their messages stay, with no author (SET_NULL).
  • related_name="chat_messages" lets you write user.chat_messages. Plain messages would read as if it had to do with Django’s messages framework, which is also installed.
  • Meta.ordering keeps every list of messages oldest first, the way a chat log reads.

You also deleted the chat/migrations/ folder in Part 1 of the Channels tutorial. Naming the app tells makemigrations to create it again:

uv run python manage.py makemigrations chat
uv run python manage.py migrate

You should see:

Migrations for 'chat':
  chat/migrations/0001_initial.py
    + Create model Room
    + Create model Message
Operations to perform:
  Apply all migrations: admin, auth, catalog, chat, contenttypes, sessions
Running migrations:
  Applying chat.0001_initial... OK

Your Channels tutorial also deleted chat/admin.py. Create it again, so you can look at rooms and messages in the admin site:

# chat/admin.py
from django.contrib import admin

from .models import Message, Room

admin.site.register(Room)
admin.site.register(Message)

Save messages from the consumer

The ChatConsumer you wrote in Part 3 of the Channels tutorial is asynchronous. As that page’s note said, an asynchronous consumer can still use Django models, through the model methods that start with a, like aget_or_create() and acreate(), as long as it calls channels.db.aclose_old_connections() before it uses the database. Put the following code in chat/consumers.py. The highlighted lines are new:

# chat/consumers.py
import json

from channels.db import aclose_old_connections
from channels.generic.websocket import AsyncWebsocketConsumer

from .models import Message, Room


class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
        self.room_group_name = f"chat_{self.room_name}"

        # Find the room in the database, or create it the first time anyone joins
        await aclose_old_connections()
        self.room, _ = await Room.objects.aget_or_create(name=self.room_name)

        # Join room group
        await self.channel_layer.group_add(self.room_group_name, self.channel_name)

        await self.accept()

    async def disconnect(self, close_code):
        # Leave room group
        await self.channel_layer.group_discard(self.room_group_name, self.channel_name)

    # Receive message from WebSocket
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json["message"]

        # Save the message before anyone sees it
        await aclose_old_connections()
        user = self.scope["user"]
        await Message.objects.acreate(
            room=self.room,
            author=user if user.is_authenticated else None,
            content=message,
        )

        # Send message to room group
        await self.channel_layer.group_send(
            self.room_group_name, {"type": "chat.message", "message": message}
        )

    # Receive message from room group
    async def chat_message(self, event):
        message = event["message"]

        # Send message to WebSocket
        await self.send(text_data=json.dumps({"message": message}))
  • aget_or_create() returns a (room, created) tuple. The first person to open /chat/lobby/ creates the lobby room, and everyone after that joins the same row. The consumer keeps the room in self.room for as long as the WebSocket is open.
  • self.scope["user"] is the user that AuthMiddlewareStack found, the same as request.user in a view. If you’re logged in, for example at /accounts/login/ from Prep 06, your messages get you as their author. Otherwise they’re saved with no author.
  • The message is saved before it’s sent to the group. If saving fails, nobody sees a message that won’t be there when they reload the page.

Show a room’s history

Saved messages are only useful if you can see them. Update the room view in chat/views.py to load the room’s messages:

# chat/views.py
from django.shortcuts import render

from .models import Room


def index(request):
    return render(request, "chat/index.html")


def room(request, room_name):
    room = Room.objects.filter(name=room_name).first()
    chat_messages = room.messages.all() if room else []
    return render(
        request,
        "chat/room.html",
        {"room_name": room_name, "chat_messages": chat_messages},
    )

The context variable is called chat_messages, not messages. Django’s messages framework already puts a messages variable in every template’s context, and yours would hide it.

The view doesn’t create the room: opening the page isn’t enough to make a room, but connecting the WebSocket is. If the room doesn’t exist yet, there’s no history to show.

In chat/templates/chat/room.html, change only the textarea line, so that it starts with one line per saved message:

<!-- chat/templates/chat/room.html -->
<textarea id="chat-log" cols="100" rows="20">{% for chat_message in chat_messages %}{{ chat_message.content }}
{% endfor %}</textarea><br>

That’s the same format the JavaScript in the page uses when a new message arrives: the message, then a newline. The line break inside the for loop is that newline, so keep it exactly where it is, and don’t add spaces or line breaks anywhere else between the textarea tags. In particular, the {% endfor %} line must start at the very beginning of the line, even though the rest of the file is indented: any spaces before it would end up at the start of every message but the first.

Try it

Make sure Redis is running (see Part 2 of the Channels tutorial), then start the server:

uv run python manage.py runserver

Open http://127.0.0.1:8000/chat/lobby/ πŸ”— in two tabs, and send “hello” and “anyone here?” from one of them. Both show up in both tabs, as before. Now reload a tab: the messages are still there.

Go to http://127.0.0.1:8000/admin/chat/message/ πŸ”— and log in with your superuser. Your messages are listed there, and the lobby room is under Rooms. Messages you sent while logged in show your username as their author.

Finally, run the Channels tutorial’s tests again, to make sure the chat still works end to end:

uv run python manage.py test chat.tests

The rest of this page is about Django Ninja again. Join two or three more rooms, such as books and random, so the API has a few rooms to work with.

Schemas

A schema describes the shape of data going in or out of your API. Django Ninja schemas are Pydantic models: a class with type-annotated fields.

# locallibrary_config/api.py
from datetime import datetime

from ninja import Schema


class RoomIn(Schema):
    name: str
    topic: str = ""


class RoomOut(Schema):
    id: int
    name: str
    topic: str
    created_at: datetime

RoomIn is what clients send, and RoomOut is what they get back. They’re separate so a client can’t set id or created_at (or, later, fields you’d rather keep private). topic has a default, so it’s optional in RoomIn.

A schema doesn’t have to match a model. You’ll use plain Schema classes for anything that isn’t a table: login payloads, computed results, aggregates.

The same, generated from the model

When a schema mirrors a model, as these do, it only repeats what the model already says. ModelSchema reads the fields from the model instead:

# locallibrary_config/api.py
from ninja import ModelSchema

from chat.models import Room


class RoomIn(ModelSchema):
    class Meta:
        model = Room
        fields = ["name", "topic"]


class RoomOut(ModelSchema):
    class Meta:
        model = Room
        fields = ["id", "name", "topic", "created_at"]

These two classes behave the same as the hand-written ones, with the types and the topic default taken from the model. You also get the model’s constraints: name is limited to 50 characters because of max_length=50. When you add or change a model field, the schema follows.

The rest of this Quick Start uses the ModelSchema versions. You can mix both kinds freely, and a ModelSchema can also declare extra fields of its own (see ModelSchema).

Read: list and detail

Use response= to tell Django Ninja what an endpoint returns:

# locallibrary_config/api.py
from django.shortcuts import get_object_or_404


@api.get("/rooms", response=list[RoomOut])
def list_rooms(request):
    return Room.objects.all()


@api.get("/rooms/{room_id}", response=RoomOut)
def get_room(request, room_id: int):
    return get_object_or_404(Room, id=room_id)
  • You return querysets and model instances directly. Django Ninja reads the fields listed in the schema and serializes them to JSON. There’s no serializer to call.
  • {room_id} in the path becomes the room_id: int argument, converted and validated like the query parameters on the previous page.
  • Django’s get_object_or_404 works as usual: a missing room is a 404 JSON response.
  • Only the fields in RoomOut are sent back, even if the object has more. The schema is also the contract shown in the docs.

GET /api/rooms/1:

{
    "id": 1,
    "name": "lobby",
    "topic": "",
    "created_at": "2026-10-19T14:02:11.482Z"
}

Create, update, delete

A schema-typed argument is read from the JSON request body. The highlighted lines are the ones explained below:

# locallibrary_config/api.py
@api.post("/rooms", response={201: RoomOut})
def create_room(request, payload: RoomIn):
    return Room.objects.create(**payload.dict())


@api.put("/rooms/{room_id}", response=RoomOut)
def update_room(request, room_id: int, payload: RoomIn):
    room = get_object_or_404(Room, id=room_id)
    for attr, value in payload.dict().items():
        setattr(room, attr, value)
    room.save()
    return room


@api.delete("/rooms/{room_id}", response={204: None})
def delete_room(request, room_id: int):
    get_object_or_404(Room, id=room_id).delete()
  • payload: RoomIn means “parse the body as RoomIn”. By the time your function runs, the data is valid and typed.
  • response={201: RoomOut} maps a status code to a schema. With only one code listed, that’s the code every response gets, so create_room returns 201 and delete_room returns 204 with an empty body.
  • update_room mixes a path parameter and a body in one signature. Django Ninja works out where each argument comes from.
  • Deleting a room also deletes its messages, because of on_delete=models.CASCADE on Message.room.

Send a bad body, such as {"topic": 42}, and the error lists every problem at once:

{
    "detail": [
        {"type": "missing", "loc": ["body", "payload", "name"], "msg": "Field required"},
        {"type": "string_type", "loc": ["body", "payload", "topic"], "msg": "Input should be a valid string"}
    ]
}

Open /api/docs again. All five room endpoints are there, with request and response schemas, and you can create a room from the browser with Try it out. A room you create there is a real room: open /chat/<its name>/ and chat in it.

More than one status code

Try creating a room called lobby with Try it out. You get a 500 error, not a helpful answer: the name column is unique, so the database refuses the second lobby with an IntegrityError.

When an endpoint can answer in different ways, list every status code in response and return Status(code, data) to pick one. Replace create_room with a version that creates a room, or reports a clash with an existing one:

# locallibrary_config/api.py
from ninja import Schema, Status


class Conflict(Schema):
    message: str
    existing_id: int


@api.post("/rooms", response={201: RoomOut, 409: Conflict})
def create_room(request, payload: RoomIn):
    existing = Room.objects.filter(name=payload.name).first()
    if existing:
        return Status(409, {"message": "A room with that name already exists", "existing_id": existing.id})
    return Status(201, Room.objects.create(**payload.dict()))

Each response is validated against the schema for its code, and both appear in the interactive docs. See Responses for more.

(Renaming a room to a name that’s taken with PUT has the same problem. Fixing update_room the same way is a good exercise.)

Pagination

A list endpoint that returns every row won’t last long in production. Add one decorator:

# locallibrary_config/api.py
from ninja.pagination import paginate


@api.get("/rooms", response=list[RoomOut])
@paginate
def list_rooms(request):
    return Room.objects.all()

The view still returns a plain queryset. @paginate adds limit and offset query parameters, slices the queryset in the database, and wraps the result:

{
    "items": [{"id": 1, "name": "lobby", ...}],
    "count": 3
}

Page-number and cursor pagination, or your own class, are one argument away. See Pagination.

Filtering

Filters are also a schema: a FilterSchema, read from the query string. Each field maps to an ORM lookup:

# locallibrary_config/api.py
from datetime import timedelta
from typing import Annotated

from django.db.models import Q
from django.utils import timezone
from ninja import FilterLookup, FilterSchema, Query

...


class RoomFilter(FilterSchema):
    search: Annotated[str | None, FilterLookup("name__icontains")] = None
    topic: Annotated[str | None, FilterLookup("topic__icontains")] = None
    recent: bool | None = None

    def filter_recent(self, value: bool) -> Q:
        week_ago = timezone.now() - timedelta(days=7)
        return Q(created_at__gte=week_ago) if value else Q(created_at__lt=week_ago)


@api.get("/rooms", response=list[RoomOut])
@paginate
def list_rooms(request, filters: Query[RoomFilter]):
    return filters.filter(Room.objects.order_by("name"))
  • search and topic map to icontains lookups on the room’s name and topic.
  • recent needs custom logic, so a filter_recent method returns a Q object: rooms created in the last week, or, with recent=false, the ones before that.
  • Filters left out of the request are skipped, and everything is validated. ?recent=maybe gets a 422, not a server error.

Now these all work, and combine with pagination:

/api/rooms?search=lob
/api/rooms?recent=true&topic=books
/api/rooms?recent=true&limit=10&offset=20

See Filtering for combining filters with OR, filtering on related models, and more.

What you have so far

Saved chat messages, plus about 60 lines of code that give you a paginated, filterable, validated and documented CRUD API:

Method Path
GET /api/rooms list, with search, topic, recent, limit, offset
POST /api/rooms create
GET /api/rooms/{room_id} detail
PUT /api/rooms/{room_id} update
DELETE /api/rooms/{room_id} delete

Right now, though, anyone can create or delete rooms, and the API can’t show or post messages. Next: Auth, Routers & Next Steps.

3 - Quick Start Part 3: Auth, Routers & Next Steps

Move the endpoints into the chat app, require login for writes, and let signed-in users post messages to a room.

The rooms API works, but it all lives in one file and anyone can change data. On this page you’ll move the endpoints into the chat app, require login for writes, and let signed-in users post messages that show up in the chat page right away.

Move the endpoints into a router

A Router is a group of endpoints that you mount on the API under a prefix, much like Django’s include(). Each app can own its endpoints and the project just wires them together.

First, move the schemas to chat/schemas.py, unchanged: RoomIn, RoomOut, Conflict and RoomFilter. Then chat/api.py gets the endpoints, with @api replaced by @router and /rooms dropped from the paths:

# chat/api.py
from ninja import Router

router = Router(tags=["rooms"])


@router.get("/", response=list[RoomOut])
@paginate
def list_rooms(request, filters: Query[RoomFilter]):
    ...


@router.get("/{room_id}", response=RoomOut)
def get_room(request, room_id: int):
    ...

# ... and the rest

The chat app now has both chat/views.py, with the views for its HTML pages, and chat/api.py, with its API endpoints. They don’t interfere: chat/urls.py routes to the views, and the API routes to the router.

locallibrary_config/api.py is now only wiring. The hello and add endpoints from page 1 were only for practice, so they go too:

# locallibrary_config/api.py
from ninja import NinjaAPI

from chat.api import router as rooms_router

api = NinjaAPI()

api.add_router("/rooms", rooms_router)

The router’s / becomes /api/rooms/ and /{room_id} becomes /api/rooms/{room_id}. Note the trailing slash on the list: from now on, list and create rooms at /api/rooms/. A GET of /api/rooms still works, because Django redirects it to /api/rooms/, but a POST to it fails, because Django can’t redirect a POST. Thanks to tags=["rooms"], the interactive docs now group these endpoints under a rooms heading. Routers can be nested, and each one can carry its own auth, tags and other settings. See Routers.

Messages

The original Quick Start adds a model here for “user X is going to event Y”. Your chat already has its counterpart, “user X said Y in room Z”: the Message model from page 2. All it needs is schemas. Add them to chat/schemas.py, with Message added to its model import, and the room nested inside each message:

# chat/schemas.py
class MessageIn(ModelSchema):
    class Meta:
        model = Message
        fields = ["content"]


class MessageOut(ModelSchema):
    room: RoomOut

    class Meta:
        model = Message
        fields = ["id", "author", "content", "created_at"]

Declaring room: RoomOut tells Django Ninja to serialize the related Room with RoomOut, instead of returning only its id. author isn’t declared, so it comes back the default way for a foreign key: as the user’s id, or null for a message posted by someone who wasn’t logged in.

Require login

Turn on auth for the whole API in locallibrary_config/api.py:

# locallibrary_config/api.py
from ninja import NinjaAPI
from ninja.security import django_auth

from chat.api import router as rooms_router

api = NinjaAPI(auth=django_auth)

api.add_router("/rooms", rooms_router)

Every endpoint now requires a logged-in user, so you have to opt the public ones back out. Here’s the complete chat/api.py:

# chat/api.py
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.shortcuts import get_object_or_404
from ninja import Query, Router, Status
from ninja.errors import HttpError
from ninja.pagination import paginate

from .models import Message, Room
from .schemas import Conflict, MessageIn, MessageOut, RoomFilter, RoomIn, RoomOut

router = Router(tags=["rooms"])


@router.get("/", response=list[RoomOut], auth=None)
@paginate
def list_rooms(request, filters: Query[RoomFilter]):
    return filters.filter(Room.objects.order_by("name"))


@router.get("/mine", response=list[MessageOut])
def my_messages(request):
    return Message.objects.filter(author=request.auth).select_related("room")


@router.get("/{room_id}", response=RoomOut, auth=None)
def get_room(request, room_id: int):
    return get_object_or_404(Room, id=room_id)


@router.post("/", response={201: RoomOut, 409: Conflict})
def create_room(request, payload: RoomIn):
    existing = Room.objects.filter(name=payload.name).first()
    if existing:
        return Status(409, {"message": "A room with that name already exists", "existing_id": existing.id})
    return Status(201, Room.objects.create(**payload.dict()))


@router.put("/{room_id}", response=RoomOut)
def update_room(request, room_id: int, payload: RoomIn):
    room = get_object_or_404(Room, id=room_id)
    for attr, value in payload.dict().items():
        setattr(room, attr, value)
    room.save()
    return room


@router.delete("/{room_id}", response={204: None})
def delete_room(request, room_id: int):
    get_object_or_404(Room, id=room_id).delete()


@router.get("/{room_id}/messages", response=list[MessageOut], auth=None)
@paginate
def list_messages(request, room_id: int):
    room = get_object_or_404(Room, id=room_id)
    return room.messages.select_related("room")


@router.post("/{room_id}/messages", response={201: MessageOut})
def post_message(request, room_id: int, payload: MessageIn):
    room = get_object_or_404(Room, id=room_id)
    if not payload.content.strip():
        raise HttpError(400, "Messages can't be blank")
    message = Message.objects.create(room=room, author=request.auth, content=payload.content)
    # Send it to everyone in the room's chat page, the way ChatConsumer.receive does
    async_to_sync(get_channel_layer().group_send)(
        f"chat_{room.name}", {"type": "chat.message", "message": message.content}
    )
    return message

What’s new:

  • NinjaAPI(auth=django_auth) requires a logged-in Django user for every endpoint, so new endpoints are protected unless you say otherwise. django_auth uses the regular Django session, the same one the admin and your catalog’s login pages use.
  • auth=None opts the three read endpoints back out, so anyone can browse rooms and read their messages, just as anyone can open a chat page. Auth can be set on the whole API, on a router (Router(auth=...)) or on a single endpoint, and the most specific one wins.
  • request.auth is whatever the authenticator returned. For django_auth that’s the User. my_messages lists the messages you posted, from the chat page while logged in or through the API, each with its room nested inside.
  • HttpError(400, "...") stops the request and returns that status with a JSON {"detail": "..."} body. There’s no need to build error responses by hand.
  • list_messages returns the messages oldest first, thanks to Meta.ordering on Message, and pages them with @paginate. select_related("room") fetches each message’s room in the same query, since MessageOut nests it.
  • post_message sends the new message to the room’s group on the channel layer, exactly like ChatConsumer.receive does. Every ChatConsumer in that room receives the chat.message event, and its chat_message method sends the message to its browser. The view is synchronous and channel layer methods are asynchronous, so it calls them through async_to_sync, like your first, synchronous consumer did in Part 2 of the Channels tutorial. That means Redis must be running for post_message to work.

An anonymous request to a protected endpoint gets a 401:

$ curl http://127.0.0.1:8000/api/rooms/mine
{"detail": "Unauthorized"}

To try it out, make sure Redis is running, log in at /admin/ (or at /accounts/login/, from Prep 06), and open /api/docs. Swagger UI reuses your session and sends the CSRF token for you, so you can post a message with Try it out.

In a second tab, open the chat page for the room you’ll post to, such as http://127.0.0.1:8000/chat/lobby/ πŸ”—. Find its id with GET /api/rooms/?search=lobby. Then, back in the docs, POST /api/rooms/1/messages (with your room’s id) and the body {"content": "hello from the API"}. The message appears in the chat tab right away, and the response has the nested schema:

{
    "room": {
        "id": 1,
        "name": "lobby",
        "topic": "",
        "created_at": "2026-10-19T14:02:11.482Z"
    },
    "id": 7,
    "author": 1,
    "content": "hello from the API",
    "created_at": "2026-10-19T15:21:40.913Z"
}

Your message and user ids will be different. Post {"content": " "} and you get 400 {"detail": "Messages can't be blank"}.

What you’ve built

In three pages, and without writing any serializers, URL patterns or docs by hand, you have a chat that remembers its messages, and an API with:

  • typed, validated path, query and body parameters,
  • schemas generated from Django models, including a nested one,
  • full CRUD, pagination and filtering,
  • session auth with public and protected endpoints,
  • proper error responses,
  • messages posted through the API that reach the chat page live, and
  • interactive OpenAPI docs that always match the code.

Where to go next

Our Django Ninja Tutorial builds a larger project step by step, in your locallibrary next to everything you’ve built so far. The Guide covers each feature in depth. Here’s where to read more about everything used in this Quick Start:

You used Read more
NinjaAPI, urls.py NinjaAPI, URLs & Reverse
@api.get, @api.post, … Operations
name: str, room_id: int Query Parameters, Path Parameters
payload: RoomIn Request Body, plus Form Data and File Uploads
ModelSchema, nested schemas ModelSchema, Schemas
response={201: ...} Responses
@paginate Pagination
FilterSchema Filtering
Router, add_router Routers
auth=django_auth, request.auth Authentication, CSRF
HttpError Errors & Exception Handling
/api/docs OpenAPI & Interactive Docs
async_to_sync, group_send Channel Layers in the Channels docs

Features you haven’t seen yet: async views, throttling, API versioning, custom renderers and a test client.