React · Material UI · Django Ninja · AWS Lambda · Terraform
A reusable full-stack template — React SPA frontend backed by a stateless Django Ninja REST API running on AWS Lambda. Fork it, rename things, and ship.
This template uses useReducer + useContext for state management — the right fit when state transitions are predictable and scoped, you want zero external dependencies, and the app doesn't need to share state across distant parts of the tree. It's a good starting point before reaching for Zustand or Redux Toolkit. It deploys to AWS Lambda, which fits the lightweight, stateless nature of this stack.
See also the Zustand template and Redux Toolkit template for heavier state needs — both deploy to AWS ECS, better suited for the more complex, long-running workloads those templates are designed for.
| Layer | Tech |
|---|---|
| Frontend | React 18, Material UI, React Router, Axios, Vite, Vitest |
| Backend | Python 3.9, Django 4.2, Django Ninja, Mangum (AWS Lambda adapter) |
| Infrastructure | Terraform — Lambda, API Gateway (HTTP), S3, CloudFront, IAM, CloudWatch |
| Quality | ESLint, Prettier, ruff, Husky pre-commit + pre-push, GitHub Actions CI |
The backend is stateless — no database, no sessions. It is designed to run ephemerally on serverless environments (AWS Lambda via API Gateway v2), wrapped with Mangum. You can develop locally using Django's built-in development server.
Django Ninja brings several advantages over a plain Django REST setup:
- Automatic OpenAPI docs — interactive Swagger UI generated from your code, no extra config needed
- Type-safe request/response — uses Python type hints and Pydantic for validation and serialisation
- Fast and async-ready — designed for high-throughput APIs with native async support
- Schema-first development — your function signatures define the contract, keeping code and docs in sync
The interactive API docs are available at http://localhost:8080/docs when running locally. Note: accessing via the Vite proxy at localhost:3000/api/docs loads the docs page but Swagger UI cannot fetch the schema through the proxy — use the direct backend URL instead.
- Python 3.9 — use pyenv to manage versions
- Node.js 22 — use
nvm useto pick the version in.nvmrc - pyenv (recommended)
- nvm (recommended)
- direnv (recommended)
- Terraform CLI — for infra changes
- AWS CLI — for deployments
# 1. Use the right Node version
nvm use
# 2. Create and activate a Python virtual environment
python -m venv .venv && source .venv/bin/activate
# 3. Install Python dependencies
pip install -r requirements-dev.txt
# 4. Set up environment variables
cp .envrc.example .envrc # fill in values, then:
direnv allow
# 5. Install Node dependencies
npm install
cd frontend && npm install && cd ..
# 6. Start both services
npm run devThe frontend is served at http://localhost:3000 and the backend at http://localhost:8080.
react-django-template/
├── frontend/ # React SPA (deploys to S3 + CloudFront)
│ ├── src/
│ │ ├── api/ # Axios client
│ │ ├── context/ # useReducer + useContext state
│ │ └── pages/ # Route-level components
│ ├── specs/ # Vitest tests + setup
│ └── vite.config.ts
├── backend/ # Django Ninja API (deploys as Lambda)
│ ├── app/ # Django app (settings, urls, api)
│ └── specs/ # pytest tests
├── requirements.txt # Python runtime dependencies
├── requirements-dev.txt # Python dev dependencies (includes requirements.txt)
├── .infrastructure/ # Terraform
│ ├── api_gateway.tf
│ ├── frontend.tf
│ ├── lambda.tf
│ ├── iam.tf
│ ├── logs.tf
│ └── environments/ # demo / staging / prod tfvars
└── .github/
└── workflows/ # CI: lint + test on every PR
From the project root:
| Command | Description |
|---|---|
npm run dev |
Start frontend + backend concurrently |
npm run dev:frontend |
Start frontend only |
npm run dev:backend |
Start backend only (port 8080) |
npm run test:frontend |
Run frontend tests |
npm run test:backend |
Run backend tests |
npm run test:frontend:coverage |
Frontend tests with coverage |
npm run test:backend:coverage |
Backend tests with coverage |
npm run lint:frontend |
Lint frontend |
npm run lint:backend |
Lint backend |
npm run format:frontend |
Format frontend |
npm run format:backend |
Format backend |
npm run clean |
Remove build artefacts and node_modules |
Backend commands (run from backend/):
ruff format app/ specs/ # format
ruff check app/ specs/ # lint
pytest # test
pytest --cov=app --cov-report term # test with coverageEnable git hooks by running pre-commit install --hook-type pre-commit --hook-type pre-push from the project root.
On every commit: ruff format + ruff check --fix
On every push: pytest
The Terraform configuration in .infrastructure/ provisions:
- Lambda function (Docker image from ECR)
- API Gateway HTTP API with CORS and
$defaultcatch-all route - S3 bucket for the React SPA (private, versioned)
- CloudFront distribution with OAC, HTTPS redirect, and SPA 404 fallback
- IAM role +
AWSLambdaBasicExecutionRolepolicy - CloudWatch log group with configurable retention
cd .infrastructure
# Initialise (once)
terraform init
# Plan against an environment
terraform plan -var-file=environments/demo.tfvars
# Apply
terraform apply -var-file=environments/demo.tfvarsSet VITE_API_URL to your API Gateway URL at build time so the bundle points at the correct backend:
cd frontend
VITE_API_URL=https://<api-id>.execute-api.<region>.amazonaws.com npm run build
aws s3 sync dist/ s3://$(terraform -chdir=../.infrastructure output -raw s3_bucket) --delete
aws cloudfront create-invalidation \
--distribution-id $(terraform -chdir=../.infrastructure output -raw cloudfront_distribution_id) \
--paths "/*"The CloudFront invalidation is required after each deploy so users receive the updated files rather than stale cached content.
Set these in your Lambda function's environment (or via Terraform tfvars):
| Variable | Dev default | Production value |
|---|---|---|
DJANGO_SECRET_KEY |
insecure dev key | A strong random secret |
DEBUG |
True |
False (or unset) |
ALLOWED_HOSTS |
* |
Your API Gateway domain |
CORS_ALLOWED_ORIGINS |
http://localhost:3000 |
Your CloudFront domain (e.g. https://app.yourdomain.com) |
ABOUT_MESSAGE |
Template API v0.0.1 (development) |
Your production value |
Set this at frontend build time (injected by Vite into the bundle):
| Variable | Dev | Production |
|---|---|---|
VITE_API_URL |
(unset — uses Vite proxy) | Your API Gateway URL |
Build and push the Docker image to ECR, then update the Lambda:
aws ecr get-login-password | docker login --username AWS --password-stdin <account>.dkr.ecr.<region>.amazonaws.com
docker build -t react-django-template-function backend/
docker tag react-django-template-function:latest <account>.dkr.ecr.<region>.amazonaws.com/react-django-template-function:latest
docker push <account>.dkr.ecr.<region>.amazonaws.com/react-django-template-function:latest
aws lambda update-function-code \
--function-name <environment>-react-django-template-function \
--image-uri <account>.dkr.ecr.<region>.amazonaws.com/react-django-template-function:latestThe GitHub Actions push-images-and-update-lambda.yml workflow automates this. It requires these repository secrets:
AWS_ACCOUNT_IDAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY
