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.

Last modified October 22, 2026: add button label (28b88d0)