DH
13 min read

Creating a Full Stack Application with Django, FastAPI, and Next.js

How to create a full stack app with Django, FastAPI and Next.js.

djangofastapinextjs

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.1 and uvicorn==0.25.0 are 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 same settings.py works 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 manually pg_dump and 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 like postgres: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? application is the Django ASGI callable — it handles /admin, /static, and anything Django's URL router matches. fastapp is the FastAPI instance, served separately on port 8001 (and proxied to /api in production). Keeping them as distinct objects means you get FastAPI's /docs and /openapi.json without 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 standalone output? Next.js's standalone mode copies only the files needed to run the app (no full node_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/standalone rather 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 — /admin and /static go to Django, /api and /docs go 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 in asgi.py is still useful for local development where you're hitting localhost:8001 directly.

Common Issues and Fixes

SymptomLikely causeFix
Event loop is closed error in FastAPI routeCalling Django ORM directly in async defWrap with sync_to_async or use plain def handler
Django admin CSS missingcollectstatic not runRun docker compose exec admin python manage.py collectstatic
CORS policy error in browserFrontend calling API directly during devAdd CORSMiddleware to fastapp in asgi.py
django.db.utils.OperationalError on startupPostgres not ready when Django startsAdd depends_on: postgres and a healthcheck to the Django service
Auth model swap errorRan migrations before setting AUTH_USER_MODELReset the database and run migrations fresh

Next Steps

Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps