Quick Start Part 3: Auth, Routers & Next Steps
7 minute read
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_authuses the regular Django session, the same one the admin and your catalog’s login pages use.auth=Noneopts 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.authis whatever the authenticator returned. Fordjango_auththat’s theUser.my_messageslists 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_messagesreturns the messages oldest first, thanks toMeta.orderingonMessage, and pages them with@paginate.select_related("room")fetches each message’s room in the same query, sinceMessageOutnests it.post_messagesends the new message to the room’s group on the channel layer, exactly likeChatConsumer.receivedoes. EveryChatConsumerin that room receives thechat.messageevent, and itschat_messagemethod sends the message to its browser. The view is synchronous and channel layer methods are asynchronous, so it calls them throughasync_to_sync, like your first, synchronous consumer did in Part 2 of the Channels tutorial. That means Redis must be running forpost_messageto 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"}.
django_auth suits browser clients on the same site. For mobile apps or other servers, use an API key or bearer token instead. It’s a small class with one authenticate() method; see Authentication. Part 4 of our Django Ninja Tutorial builds one.
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.