Part 1: Project setup & layout

One router per app, schemas from models, and task URLs nested under projects.

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.

Create the apps

You don’t create a new project: TaskFlow goes into your existing locallibrary repository, next to catalog and chat. Django and Django Ninja are already in your pyproject.toml from the Django Ninja Quickstart, so there’s nothing to install. From the folder with manage.py, create the two apps:

uv run python manage.py startapp projects
uv run 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.

In the original tutorial, taskflow is the name of the Django project package, the folder with settings.py and urls.py. Yours is called locallibrary_config, and you’ll keep it. TaskFlow still gets a taskflow package of its own, though, to hold the module that wires its routers together. It’s a plain Python package, not an app, so create it by hand: a taskflow folder next to manage.py, with an empty __init__.py file inside.

Register the two apps in locallibrary_config/settings.py, at the end of INSTALLED_APPS. 'ninja' is already there from the Quickstart. Don’t remove anything:

# 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',
    'catalog.apps.CatalogConfig',
    'chat',
    '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 new parts of your locallibrary will look like this:

catalog/            # (unchanged)
chat/               # (unchanged)
locallibrary_config/
├── settings.py
└── urls.py         # mounts the TaskFlow API next to the Quickstart's
taskflow/
├── __init__.py
└── api.py          # NinjaAPI: wires the routers together
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 taskflow package holds only the wiring.

Models

A project, and a task that belongs to one:

# projects/models.py
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
# tasks/models.py
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
$ uv run 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
$ uv run 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:

# projects/schemas.py
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:

# tasks/schemas.py
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:

# projects/api.py
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 in response=. Status(204, None) sends an empty response.

Go deeper: Routers, Responses

Wire the routers together

The taskflow/api.py module creates the NinjaAPI and mounts each app’s router under a prefix:

# taskflow/api.py
from ninja import NinjaAPI

api = NinjaAPI(title="TaskFlow API", urls_namespace="taskflow")

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.

Your locallibrary_config/urls.py already mounts the Quickstart’s API at api/. Mount TaskFlow’s API once, like any other set of views, in the same style as the other blocks in that file. Add this to the end of the file:

# locallibrary_config/urls.py
# (after the existing blocks)
# Mount the TaskFlow API from the Django Ninja Tutorial
from taskflow.api import api as taskflow_api

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

Both APIs are named api in their own modules, so this import renames TaskFlow’s to taskflow_api. Leave the admin, catalog, chat, redirect, static-files, and Quickstart api/ entries as they are.

The project routes now live at /taskflow/api/projects/ and /taskflow/api/projects/{project_id}. In the original tutorial they’re at /api/projects/. In this copy every TaskFlow URL starts with /taskflow/api/ instead, because /api/ already belongs to your Quickstart API.

Go deeper: The NinjaAPI Instance

Tasks under a project

Tasks always belong to a project, so their URLs sit under it: /taskflow/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:

# tasks/api.py
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 an int.
  • task_id is part of the operation’s own path (/{task_id}), so a plain int is enough, like project_id in the projects router.
  • create_task takes 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_task filters on both ids, so /projects/3/tasks/2 returns 404 when task 2 belongs to project 1.

Why not a nested router?

You could get the same URLs by nesting routers inside the projects app:

router.add_router("/{project_id}/tasks", tasks_router)

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

uv run python manage.py runserver

The quickest way to send the requests below is the interactive docs at http://127.0.0.1:8000/taskflow/api/docs: click an endpoint, then Try it out. (You’ll see more of that page at the end of this section.)

Create a project with POST /taskflow/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 /taskflow/api/projects/1/tasks/ with {"title": "Draft the sitemap"}, then again with {"title": "Pick a color palette", "description": "Two options"}. GET /taskflow/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 /taskflow/api/projects/3/tasks/2:

{
    "detail": "Not Found: No Task matches the given query."
}

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/taskflow/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    /taskflow/api/projects/                                    ['projects']
POST   /taskflow/api/projects/                                    ['projects']
GET    /taskflow/api/projects/{project_id}                        ['projects']  project_id (path)
PUT    /taskflow/api/projects/{project_id}                        ['projects']  project_id (path)
DELETE /taskflow/api/projects/{project_id}                        ['projects']  project_id (path)
GET    /taskflow/api/projects/{project_id}/tasks/                 ['tasks']  project_id (path)
POST   /taskflow/api/projects/{project_id}/tasks/                 ['tasks']  project_id (path)
GET    /taskflow/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 Router in api.py and its schemas in schemas.py. taskflow/api.py only creates the NinjaAPI and 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.
  • ModelSchema generates schemas from models. A model field without a default is required in the schema, and default="" makes it optional.
  • A router mounted under /projects/{project_id}/tasks reads the prefix parameter with project_id: Path[int]. Without Path, 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.

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