Tutorial
Creating a Full Stack Application with Django, FastAPI, and Next.js
How to create a full stack app with Django, FastAPI and Next.js.
In this tutorial, we will create a full stack application using Django for the backend, FastAPI for the API, and Next.js for the frontend. This stack is battle-tested in production SaaS projects and gives you strong scaffolding, fast async APIs, and a modern frontend — all running in Docker containers you can ship to any cloud provider.
- Django: We use Django for its strong scaffolding abilities, primarily for its models, migrations, and admin capabilities. SQLite would work for prototypes, but PostgreSQL is the right default for production: it handles concurrent writes safely, supports row-level locking, and integrates cleanly with Django’s ORM.
- FastAPI: We choose FastAPI because of its speed, OpenAPI support, built-in interactive docs, and Pydantic schemas and validation. FastAPI runs on Starlette and is fully ASGI-native, which makes it a natural fit alongside Django’s ASGI adapter — both can share the same uvicorn worker without thread-pool workarounds.
- Next.js: We utilize Next.js for its flexibility in providing server-side rendering (SSR), static site generation (SSG), and incremental static regeneration (ISR) on a per-page basis.
The source code for this tutorial can be found at https://github.com/damianhodgkiss/next-django-fastapi-fullstack-tutorial/. However, I recommend following the full tutorial to gain a comprehensive understanding of each step.
We choose to use Docker containers for hosting portability. While we may miss out on some instant auto-deployment features like those offered by Vercel, this configuration is designed for flexibility and major cloud providers such as AWS, Azure, and GCP. Additionally, self-hosting on a VPS with Portainer would be fairly straightforward with this stack too.
Why This Stack? Key Architectural Tradeoffs
Before diving in, it’s worth understanding why this combination makes sense and where the friction points are.
Django vs. FastAPI routing — Django handles the admin interface, ORM, auth, and migrations. FastAPI handles all external API routes. The key insight is that they share the same Python process and database connection, but are exposed on different paths via Nginx (/admin → Django, /api → FastAPI). You are not running two separate backends; you’re mounting two ASGI apps in the same container. This means Django models are fully importable inside FastAPI route handlers — no duplication of database logic.
PostgreSQL vs. SQLite — Django ships with SQLite enabled by default, which is fine for development but breaks under concurrent writes (e.g., multiple API requests hitting the same table simultaneously). PostgreSQL handles connection pooling, row-level locking, and is required for production deployments of any SaaS with real users.
Async mismatch to watch for — Django’s ORM is synchronous by default. If you call a Django ORM query directly inside a FastAPI async def route handler, you’ll block the event loop. Two safe options: use sync_to_async from asgiref (bundled with Django), or keep FastAPI route handlers as plain def (FastAPI runs them in a thread pool automatically). Example:
from asgiref.sync import sync_to_async
from fastapi import FastAPI
from myapp.models import MyModel
fastapp = FastAPI()
@fastapp.get("/items")
async def list_items():
# Safe: wraps synchronous ORM call
items = await sync_to_async(list)(MyModel.objects.all())
return items
CORS — When your Next.js frontend (port 3000) talks directly to FastAPI (port 8001) during development, you’ll hit CORS errors. Add CORSMiddleware to your FastAPI app in asgi.py:
from fastapi.middleware.cors import CORSMiddleware
origins = [
"http://localhost:3000", # Next.js dev server
# Add your production domain here
]
fastapp.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
In production, Nginx handles routing so both services share port 80 and CORS isn’t an issue — but you still want this in place for local development.
Prerequisites
Before we begin, ensure that you have the following installed:
- Python: If not already installed, download and install Python from the official website.
- django-admin: If not installed, run the following command to install it:
pip install django
Note: Since this is a Docker application, we only need to install enough to set up Django and run create-next-app on the host machine.
Steps
1. Create Project Directory
Create a new directory for the project and navigate into it:
mkdir next-django-fastapi-fullstack
cd next-django-fastapi-fullstack
2. Set Up Django Backend
2.1 Start Django Project
Create a backend directory and start a new Django project:
mkdir backend
django-admin startproject mysaas backend
2.2 Create Dockerfile
Create a Dockerfile in the backend directory with the following content:
FROM python:3.11.5-bullseye
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
RUN pip install --upgrade pip
COPY . /app/
RUN mkdir -p /app/staticfiles/
RUN pip install -r requirements.txt
EXPOSE 8000
CMD ["uvicorn", "mysaas.asgi:application", "--host", "0.0.0.0"]
2.3 Configure Python Packages
Edit the requirements.txt file in the backend directory to include the necessary packages:
Django==5.0.3
uvicorn==0.25.0
fastapi==0.109.1
django-use-email-as-username==1.4.0
psycopg2==2.9.9
Note on versions: Pin your dependencies as shown above.
fastapi==0.109.1anduvicorn==0.25.0are a stable pairing. If you upgrade FastAPI, check the FastAPI changelog for Pydantic v2 migration notes, as v2 introduced breaking schema changes.
2.4 Configure Database and Email Authentication
Edit the settings.py file in the backend/mysaas directory to configure the database and email authentication:
ALLOWED_HOSTS = ['localhost']
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv("POSTGRES_DB", default="mysaas"),
"USER": os.getenv("POSTGRES_USER", default="mysaas"),
"PASSWORD": os.getenv("POSTGRES_PASSWORD", default="mysaas"),
"HOST": os.getenv("POSTGRES_HOST", default="postgres"),
"PORT": os.getenv("POSTGRES_PORT", default="5432"),
}
}
INSTALLED_APPS = [
"django_use_email_as_username.apps.DjangoUseEmailAsUsernameConfig",
...
]
Why PostgreSQL over SQLite? Django defaults to SQLite for convenience, but SQLite uses file-level locking — two simultaneous writes will queue or fail under load. PostgreSQL uses row-level locking and a proper connection model, making it the right default for any project expecting real traffic. Using environment variables (via
os.getenv) means the samesettings.pyworks for local Docker, staging, and production without code changes.
2.5 Initialize PostgreSQL
Create a postgres directory and an init.sql file within it:
CREATE USER mysaas WITH PASSWORD 'mysaas';
CREATE DATABASE mysaas;
GRANT ALL PRIVILEGES ON DATABASE mysaas TO mysaas;
\connect mysaas;
GRANT CREATE ON SCHEMA public TO mysaas;
Add the following configuration for the PostgreSQL service in the docker-compose.yml file:
postgres:
image: pgautoupgrade/pgautoupgrade:latest
restart: always
volumes:
- postgres-data:/var/lib/postgresql/data
- ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
environment:
POSTGRES_PASSWORD: postgres
volumes:
postgres-data:
About
pgautoupgrade: This image automatically upgrades the PostgreSQL data directory when you pull a newer Postgres version, so you don’t have to manuallypg_dumpand restore. It’s useful for long-running projects that want to stay current without a migration ceremony. For teams preferring explicit control, pin to a specific Postgres version likepostgres:16.
2.6 Run Django
Start the Django application using Docker Compose:
docker compose up --build -d
Check if Django is running by opening http://localhost:8000 in your browser.
2.7 Configure Custom User Model with django-use-email-as-username
Before we configure the database and email authentication, let’s set up a custom user model using django-use-email-as-username.
Do this before your first migration. Django’s auth system bakes in the User model early — swapping it after running migrations requires resetting the database. Set this up now.
First, create a custom users app:
docker compose exec admin python manage.py create_custom_user_app users
Now, edit the backend/mysaas/settings.py file to use the custom user model:
# Add 'users' to INSTALLED_APPS
INSTALLED_APPS = [
"users.apps.UsersConfig", # Add this line
# ... other installed apps ...
]
# Set the custom user model
AUTH_USER_MODEL = 'users.User'
2.8 Configure Static Files
Now we need to configure static files to ensure the Django admin interface works correctly.
Edit backend/mysaas/settings.py to set the STATIC_ROOT:
# Add this line at the end of the file
STATIC_ROOT = 'static'
Edit backend/mysaas/urls.py to serve static files during development:
from django.contrib import admin
from django.urls import path
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path("admin/", admin.site.urls),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
Collect static files by running the following command:
docker compose exec admin python manage.py collectstatic
This command will gather all static files from your apps and place them in the directory specified by STATIC_ROOT.
2.9 Run Migrations
Run the database migrations:
docker compose exec admin python manage.py migrate
Congratulations! Django is now configured.
3. Set Up FastAPI
3.1 Edit ASGI Configuration
Edit the asgi.py file in the backend/mysaas directory. This is where Django and FastAPI are wired together — both run in the same uvicorn process, exposed on different mount points:
"""
ASGI config for mysaas project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysaas.settings")
application = get_asgi_application()
fastapp = FastAPI(
servers=[
{
"url": "/api/v1",
"description": "V1",
}
]
)
# CORS: allow Next.js dev server to call the API directly
fastapp.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def init(app: FastAPI):
@app.get("/health")
def health_check():
return {'status': 'ok'}
init(fastapp)
Why two separate callables?
applicationis the Django ASGI callable — it handles/admin,/static, and anything Django’s URL router matches.fastappis the FastAPI instance, served separately on port 8001 (and proxied to/apiin production). Keeping them as distinct objects means you get FastAPI’s/docsand/openapi.jsonwithout conflicts with Django’s routing.
3.2 Update Docker Compose Configuration
Add the FastAPI service to the docker-compose.yml file:
api:
build:
context: backend
dockerfile: Dockerfile
image: backend:latest
ports:
- '8001:8000'
env_file:
- ./backend/.env
volumes:
- ./backend:/app
command: uvicorn mysaas.asgi:fastapp --host 0.0.0.0 --reload
depends_on:
- postgres
3.3 Run FastAPI
Start the FastAPI service using Docker Compose:
docker compose up --build -d
Check if FastAPI is running by opening http://localhost:8001/docs in your browser. You should see the interactive Swagger UI with the /health endpoint listed.
4. Set Up Next.js Frontend
4.1 Create Next.js App
Create a new Next.js app using the following command:
npx create-next-app@latest frontend --tailwind --typescript --eslint --app --src-dir --import-alias "@/*"
4.2 Create Dockerfile
Create a Dockerfile in the frontend directory:
FROM node:21-bookworm AS base
ARG DEBIAN_FRONTEND=noninteractive
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
# ENV NEXT_TELEMETRY_DISABLED 1
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
libc6-dev \
libvips-dev \
build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN chown node:node /app
## Install dependencies based on the preferred package manager, and build the app
FROM base AS builder
USER root
# COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
COPY --chown=node:node . .
USER node
RUN \
if [ -f yarn.lock ]; then yarn config set global-folder /app/.yarn && yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
RUN yarn build
## Copy the built app to a new image
FROM base AS runner
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/node_modules/ ./node_modules/
USER node
ENV PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/app/node_modules/.bin
EXPOSE 3000
CMD ["node", "server.js"]
Add the frontend service configuration to the docker-compose.yml file:
frontend:
build:
context: frontend
image: frontend:latest
ports:
- '3000:3000'
volumes:
- ./frontend:/app
command: yarn dev
depends_on:
- api
4.3 Configure Next.js Output Mode
Change the output mode to standalone in the next.config.mjs file:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
export default nextConfig;
Why
standaloneoutput? Next.js’s standalone mode copies only the files needed to run the app (no fullnode_modules) into.next/standalone. This dramatically reduces the Docker image size and is the recommended mode for containerised Next.js deployments. The Dockerfile above relies on this: it copies from.next/standalonerather than the full project directory.
4.4 Check Next.js
Verify that Next.js is running by opening http://localhost:3000 in your browser.
5. Configure Nginx
5.1 Create Nginx Configuration
Create an nginx directory and an nginx.conf file within it:
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
client_max_body_size 100M; # allow large file uploads
keepalive_timeout 65;
#gzip on;
#include /etc/nginx/conf.d/*.conf;
upstream admin {
server admin:8000;
}
upstream api {
server api:8000;
}
upstream frontend {
server frontend:3000;
}
server {
listen 80;
location /admin {
proxy_pass http://admin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static {
proxy_pass http://admin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /media {
proxy_pass http://admin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /openapi.json {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /docs {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
How Nginx ties the stack together: In production all three services share port 80. Nginx inspects the URL prefix and routes to the right upstream —
/adminand/staticgo to Django,/apiand/docsgo to FastAPI, and everything else hits Next.js. This means your frontend makes API calls to/api/v1/...on the same domain, which eliminates CORS entirely in production. The CORS middleware inasgi.pyis still useful for local development where you’re hittinglocalhost:8001directly.
Common Issues and Fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Event loop is closed error in FastAPI route | Calling Django ORM directly in async def | Wrap with sync_to_async or use plain def handler |
| Django admin CSS missing | collectstatic not run | Run docker compose exec admin python manage.py collectstatic |
CORS policy error in browser | Frontend calling API directly during dev | Add CORSMiddleware to fastapp in asgi.py |
django.db.utils.OperationalError on startup | Postgres not ready when Django starts | Add depends_on: postgres and a healthcheck to the Django service |
| Auth model swap error | Ran migrations before setting AUTH_USER_MODEL | Reset the database and run migrations fresh |
Next Steps
- Authentication: Add Next-Auth (Auth.js) or Clerk for user login.
- Observability: Add structured logging across all three services so you can trace a request end-to-end.
- Secrets management: Move from
.envfiles to AWS Secrets Manager or SSM Parameter Store for production deployments.