Part 1: Project setup & layout¶
Over eight parts, you'll build TaskFlow, a task tracker for teams: projects, members, tasks, labels, comments and file attachments. This first part sets up a project layout that stays tidy as the API grows. Each app gets its own router and schemas, and one small module wires them together.
What you'll learn
- How to lay out a multi-app project: one
Routerper app, oneNinjaAPIfor the project - How to mount routers with
add_router()and group them in the docs with tags - How to generate input and output schemas with
ModelSchema - How to nest one app's endpoints under another app's URL, with
Path[...]for prefix parameters
Time: about 15 minutes
Create the project¶
You'll need Django and Django Ninja, and nothing else:
pip install django-ninja
django-admin startproject taskflow .
python manage.py startapp projects
python manage.py startapp tasks
TaskFlow doesn't use Django's template views, so you can delete views.py and tests.py from both apps. The tests come back in Part 7, in their own place.
Register the apps in taskflow/settings.py:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'ninja',
'projects',
'tasks',
]
'ninja' is optional. With it, runserver serves the Swagger UI assets locally instead of from a CDN.
When this part is done, the project will look like this:
taskflow/
├── api.py # NinjaAPI: wires the routers together
├── settings.py
└── urls.py
projects/
├── api.py # Router with the project endpoints
├── models.py
└── schemas.py
tasks/
├── api.py # Router with the task endpoints
├── models.py
└── schemas.py
manage.py
The pattern is the same for every app: models in models.py, schemas in schemas.py, endpoints in api.py. The project package holds only the wiring.
Models¶
A project, and a task that belongs to one:
from django.db import models
class Project(models.Model):
name = models.CharField(max_length=100)
description = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
from django.db import models
from projects.models import Project
class Task(models.Model):
project = models.ForeignKey(Project, related_name="tasks", on_delete=models.CASCADE)
title = models.CharField(max_length=200)
description = models.TextField(default="")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
$ python manage.py makemigrations
Migrations for 'projects':
projects/migrations/0001_initial.py
+ Create model Project
Migrations for 'tasks':
tasks/migrations/0001_initial.py
+ Create model Task
$ python manage.py migrate
Tasks will get assignees, labels, a status and more in later parts. For now, a title is enough.
Schemas¶
Each app keeps its schemas in schemas.py. As in the Quick Start, there's an In schema for what clients send and an Out schema for what they get back:
from ninja import ModelSchema
from .models import Project
class ProjectIn(ModelSchema):
class Meta:
model = Project
fields = ["name", "description"]
class ProjectOut(ModelSchema):
class Meta:
model = Project
fields = ["id", "name", "description", "created_at"]
ProjectIn is generated entirely from the model. Neither name nor description has a default, so both are required.
The task schemas follow the same pattern:
from ninja import ModelSchema
from .models import Task
class TaskIn(ModelSchema):
class Meta:
model = Task
fields = ["title", "description"]
class TaskOut(ModelSchema):
class Meta:
model = Task
fields = ["id", "project", "title", "description", "created_at"]
Task.description has default="", so TaskIn makes it optional, with an empty string as the default. TaskIn has no project field. The project comes from the URL, as you'll see below. In TaskOut, the project foreign key is serialized as the project's id. Part 2 replaces ids like this with nested objects where that helps.
Go deeper: ModelSchema
The projects router¶
Each app's endpoints live on a Router in the app's api.py. Here's full CRUD for projects:
from django.shortcuts import get_object_or_404
from ninja import Router, Status
from .models import Project
from .schemas import ProjectIn, ProjectOut
router = Router(tags=["projects"])
@router.get("/", response=list[ProjectOut])
def list_projects(request):
return Project.objects.order_by("id")
@router.post("/", response={201: ProjectOut})
def create_project(request, payload: ProjectIn):
return Status(201, Project.objects.create(**payload.dict()))
@router.get("/{project_id}", response=ProjectOut)
def get_project(request, project_id: int):
return get_object_or_404(Project, id=project_id)
@router.put("/{project_id}", response=ProjectOut)
def update_project(request, project_id: int, payload: ProjectIn):
project = get_object_or_404(Project, id=project_id)
for attr, value in payload.dict().items():
setattr(project, attr, value)
project.save()
return project
@router.delete("/{project_id}", response={204: None})
def delete_project(request, project_id: int):
get_object_or_404(Project, id=project_id).delete()
return Status(204, None)
- The router doesn't know where it will be mounted. Its paths are relative (
/,/{project_id}), and the prefix is set when the project wires it in. tags=["projects"]applies to every operation on the router. The interactive docs group endpoints by tag, so each app gets its own section.Status(201, ...)picks the status code from the ones declared inresponse=.Status(204, None)sends an empty response.
Note
The Quick Start returned (status, body) tuples. They still work, but they're deprecated in favor of Status(...), which is what TaskFlow uses throughout.
Wire the routers together¶
The project-level api.py creates the NinjaAPI and mounts each app's router under a prefix:
from ninja import NinjaAPI
api = NinjaAPI(title="TaskFlow API")
api.add_router("/projects", "projects.api.router")
api.add_router("/projects/{project_id}/tasks", "tasks.api.router")
add_router() accepts the router object or its dotted import path. With the string form, taskflow/api.py has no imports from the apps, and each router module is imported only when add_router() runs. As the apps start importing each other's models, that helps you avoid circular imports.
urls.py mounts the API once, like any other set of views:
from django.contrib import admin
from django.urls import path
from .api import api
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", api.urls),
]
The project routes now live at /api/projects/ and /api/projects/{project_id}.
Go deeper: The NinjaAPI Instance
Tasks under a project¶
Tasks always belong to a project, so their URLs sit under it: /api/projects/{project_id}/tasks/. The second add_router() call above sets that up. Its prefix contains a {project_id} placeholder, and every operation on the tasks router can read it:
from django.shortcuts import get_object_or_404
from ninja import Path, Router, Status
from projects.models import Project
from .models import Task
from .schemas import TaskIn, TaskOut
router = Router(tags=["tasks"])
@router.get("/", response=list[TaskOut])
def list_tasks(request, project_id: Path[int]):
project = get_object_or_404(Project, id=project_id)
return project.tasks.order_by("id")
@router.post("/", response={201: TaskOut})
def create_task(request, project_id: Path[int], payload: TaskIn):
project = get_object_or_404(Project, id=project_id)
return Status(201, Task.objects.create(project=project, **payload.dict()))
@router.get("/{task_id}", response=TaskOut)
def get_task(request, project_id: Path[int], task_id: int):
return get_object_or_404(Task, id=task_id, project_id=project_id)
project_id: Path[int]reads the value from the URL prefix and converts it to anint.task_idis part of the operation's own path (/{task_id}), so a plainintis enough, likeproject_idin the projects router.create_tasktakes the project from the URL, not from the body. A client can't create a task in one project by posting it to another project's URL.get_taskfilters on both ids, so/projects/3/tasks/2returns 404 when task 2 belongs to project 1.
Don't forget Path[...] on prefix parameters
Django Ninja only treats an argument as a path parameter automatically if it appears in the operation's own path. It doesn't look at the router's prefix. If you write project_id: int in list_tasks, it becomes a required query parameter. The docs then ask for ?project_id=, and the {project_id} in the URL is ignored. GET /api/projects/1/tasks/ fails:
With Path[int], the value is read from the URL where it belongs.
Why not a nested router?¶
You could get the same URLs by nesting routers inside the projects app:
That works, but the projects app would then have to import the tasks app. Mounting both routers in taskflow/api.py keeps each app self-contained, and all the URL structure is in one file. Nested routers make more sense for sub-routers of a single app.
Go deeper: Path Parameters, Routers
Try it¶
Create a project with POST /api/projects/. The body is {"name": "Website redesign", "description": "New marketing site"}, and the response is 201:
{
"id": 1,
"name": "Website redesign",
"description": "New marketing site",
"created_at": "2026-09-29T09:30:27.635Z"
}
A second project, {"name": "Mobile app", "description": "iOS and Android"}, gets id 2:
{
"id": 2,
"name": "Mobile app",
"description": "iOS and Android",
"created_at": "2026-09-29T09:30:27.636Z"
}
Leave out the required fields, as in {}, and you get a 422 that points at each missing one:
{
"detail": [
{
"type": "missing",
"loc": ["body", "payload", "name"],
"msg": "Field required"
},
{
"type": "missing",
"loc": ["body", "payload", "description"],
"msg": "Field required"
}
]
}
Now add two tasks to project 1. POST /api/projects/1/tasks/ with {"title": "Draft the sitemap"}, then again with {"title": "Pick a color palette", "description": "Two options"}. GET /api/projects/1/tasks/ lists them:
[
{
"id": 1,
"project": 1,
"title": "Draft the sitemap",
"description": "",
"created_at": "2026-09-29T09:30:27.647Z"
},
{
"id": 2,
"project": 1,
"title": "Pick a color palette",
"description": "Two options",
"created_at": "2026-09-29T09:30:27.649Z"
}
]
Unknown ids, whether a missing project or a task under the wrong project, return a 404. GET /api/projects/3/tasks/2:
The message after Not Found only appears with DEBUG = True. In production, the body is just {"detail": "Not Found"}.
Open http://127.0.0.1:8000/api/docs. The page title is TaskFlow API, and the endpoints are grouped under projects and tasks. Here's the full list, with where each parameter comes from:
GET /api/projects/ ['projects']
POST /api/projects/ ['projects']
GET /api/projects/{project_id} ['projects'] project_id (path)
PUT /api/projects/{project_id} ['projects'] project_id (path)
DELETE /api/projects/{project_id} ['projects'] project_id (path)
GET /api/projects/{project_id}/tasks/ ['tasks'] project_id (path)
POST /api/projects/{project_id}/tasks/ ['tasks'] project_id (path)
GET /api/projects/{project_id}/tasks/{task_id} ['tasks'] project_id (path), task_id (path)
Go deeper: OpenAPI & Interactive Docs, Errors & Exception Handling
Recap¶
- Each app owns a
Routerinapi.pyand its schemas inschemas.py.taskflow/api.pyonly creates theNinjaAPIand mounts routers. add_router()sets the prefix and accepts a dotted path, which helps avoid circular imports.Router(tags=[...])groups each app's endpoints in the docs.ModelSchemagenerates schemas from models. A model field without a default is required in the schema, anddefault=""makes it optional.- A router mounted under
/projects/{project_id}/tasksreads the prefix parameter withproject_id: Path[int]. WithoutPath, it would become a query parameter. Status(code, body)picks the response status code.
Next¶
Tasks are still flat records. In Part 2: Relations & computed fields, you'll add assignees and labels, return nested objects, compute task counts, and keep the number of queries under control.