Next.js + Django REST: The Full-Stack Setup We Rely On
A deep-dive into the monorepo structure, auth patterns, and deployment pipeline we use across most of our client projects.
Why This Stack?
Choosing a technology stack is one of the most consequential decisions in a project's lifecycle. After evaluating dozens of combinations, we've settled on Next.js for the frontend and Django REST Framework for the backend. This isn't a trendy choice — it's a pragmatic one born from shipping production applications.
The Rationale
- Next.js gives us SSR, SSG, API routes, and an incredible developer experience with TypeScript
- Django REST Framework provides battle-tested ORM, admin interface, authentication, and serialization out of the box
- Together, they cover 90% of use cases without reaching for additional tools
Monorepo Structure
We organize projects in a monorepo using a clean separation between frontend and backend:
project-root/
├── apps/
│ ├── web/ # Next.js application
│ │ ├── src/
│ │ │ ├── app/ # App Router pages
│ │ │ ├── components/ # React components
│ │ │ ├── lib/ # Utilities & API client
│ │ │ └── types/ # TypeScript interfaces
│ │ ├── next.config.ts
│ │ └── package.json
│ └── api/ # Django application
│ ├── core/ # Django project settings
│ ├── apps/ # Django apps (users, orders, etc.)
│ ├── requirements/
│ └── manage.py
├── packages/
│ └── shared/ # Shared types, constants
├── docker-compose.yml
├── Makefile
└── turbo.json # Turborepo config
This structure gives us independent deployability while sharing types and constants across the stack.
Authentication Pattern
Authentication is where most full-stack setups get complex. Our approach uses HTTP-only cookies for session management with JWT tokens for API authentication:
// lib/api-client.ts — Frontend API client with automatic token refresh
import { cookies } from "next/headers";
class APIClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const cookieStore = await cookies();
const token = cookieStore.get("access_token")?.value;
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
},
});
if (response.status === 401) {
// Attempt token refresh
const refreshed = await this.refreshToken();
if (refreshed) return this.request<T>(endpoint, options);
throw new AuthError("Session expired");
}
if (!response.ok) {
throw new APIError(response.status, await response.json());
}
return response.json();
}
private async refreshToken(): Promise<boolean> {
// Implementation details...
return true;
}
}
Django Side — Token Configuration
# core/settings.py
from datetime import timedelta
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
"AUTH_HEADER_TYPES": ("Bearer",),
}
# Cookie settings for cross-origin setup
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
Deployment Pipeline
Our CI/CD pipeline uses GitHub Actions with separate workflows for frontend and backend:
- On PR — Lint, type-check, run unit tests, build Docker images
- On merge to main — Deploy to staging automatically
- On tag — Deploy to production with manual approval gate
# .github/workflows/deploy.yml (simplified)
name: Deploy
on:
push:
branches: [main]
jobs:
deploy-api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build & push API image
run: |
docker build -t registry/api:${{ github.sha }} ./apps/api
docker push registry/api:${{ github.sha }}
- name: Deploy to staging
run: kubectl set image deployment/api api=registry/api:${{ github.sha }}
deploy-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.ORG_ID }}
vercel-project-id: ${{ secrets.PROJECT_ID }}
Database Patterns
We use PostgreSQL consistently, with Django migrations managing the schema:
- Multi-tenant isolation via schema separation for SaaS products
- Read replicas for heavy query workloads
- Connection pooling with PgBouncer for high-concurrency scenarios
Performance Optimizations
Frontend
- ISR (Incremental Static Regeneration) for content pages
- React Server Components for data-heavy pages — reduces client JS bundle
- Image optimization via Next.js
<Image>with CDN-backed loader
Backend
- Select-related / prefetch-related queries to eliminate N+1 problems
- Redis caching for frequently accessed data (user profiles, feature flags)
- Celery for background task processing (emails, report generation)
Lessons Learned
-
Type safety across the stack saves hours of debugging. Generate TypeScript types from Django serializers using tools like
django-typeror OpenAPI schemas. -
Invest in developer experience early. A good Makefile, Docker Compose setup, and seed data scripts pay dividends throughout the project lifecycle.
-
Don't over-engineer authentication. HTTP-only cookies with short-lived JWTs handle 95% of cases. Only reach for OAuth if you truly need third-party integration.
-
Monitor everything from day one. Structured logging, error tracking (Sentry), and basic APM (application performance monitoring) should be in your MVP.
This stack has served us well across healthtech platforms, fintech dashboards, and e-commerce applications. It's not the only way to build full-stack applications, but it's a reliable foundation that scales predictably.
Ready to build something extraordinary?
We help startups and enterprises ship production-grade software powered by AI. Let's discuss your next project.
Get In Touch