A full-stack web application that allows university students to request student ID card replacements online. Admins review, approve, or reject requests through a dedicated dashboard. The system is built with Django on the backend, Next.js on the frontend, and deployed on AWS.
- Project Overview
- Tech Stack
- Backend Architecture
- AWS Infrastructure
- Frontend Architecture
- Authentication Flow
- Getting Started — Backend
- Getting Started — Frontend
- Environment Variables
- API Reference
- Folder Structure
Students often lose or damage their ID cards and need a formal replacement process. This portal replaces paper-based requests with a digital workflow:
- A student logs in, fills out a replacement form, and uploads a passport photo.
- An admin reviews the request, views the student's details and photo, then approves or rejects it with a reason.
- The student can log back in at any time to check the status of their request.
| Layer | Technology |
|---|---|
| Backend API | Django 4.x + Django REST Framework |
| Database | PostgreSQL (via AWS RDS) |
| File Storage | AWS S3 (passport photos) |
| Backend Hosting | AWS EC2 (Ubuntu) |
| Process Manager | Gunicorn + Nginx |
| Frontend | Next.js 14 (App Router) + TypeScript |
| Styling | Tailwind CSS |
| Frontend Hosting | Vercel (or EC2) |
| Auth | JWT (djangorestframework-simplejwt) |
The Django backend follows a standard REST API pattern. It exposes endpoints that the Next.js frontend consumes. Here is how the key pieces fit together:
Browser / Next.js Frontend
│
▼
Nginx (reverse proxy)
│
▼
Gunicorn (WSGI server)
│
▼
Django Application
├── REST Framework (API views)
├── SimpleJWT (authentication)
├── django-storages (S3 uploads)
└── psycopg2 (PostgreSQL connection)
│
├──▶ PostgreSQL (AWS RDS)
└──▶ S3 Bucket (photo uploads)
accounts — Handles user registration, login, and JWT token management.
- Custom user model with a
rolefield (studentoradmin). - Students register with email, student number, and password.
- Admins are created via the Django admin panel or management command.
id_replace — The core replacement request logic.
- A student can create a new replacement request.
- Students can only view their own requests.
- Admins can view all requests and approve or reject them.
- Rejection stores a
rejection_reasonstring on the record.
# accounts/models.py
class User(AbstractBaseUser):
email = models.EmailField(unique=True)
student_number = models.CharField(max_length=50)
role = models.CharField(choices=['student', 'admin'], default='student')
# id_replace/models.py
class IDReplacement(models.Model):
student = models.ForeignKey(User, on_delete=models.CASCADE)
full_name = models.CharField(max_length=200)
student_number = models.CharField(max_length=50)
course = models.CharField(max_length=200)
year_of_study = models.IntegerField()
reason = models.TextField(blank=True)
id_photo = models.ImageField(upload_to='id_photos/')
status = models.CharField(choices=['pending','approved','rejected'], default='pending')
rejection_reason = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)IsAuthenticated— all routes require a valid JWT token.IsAdminUser— admin-only routes checkuser.role == 'admin'.- Students hitting admin endpoints receive a
403 Forbidden.
The backend lives entirely on AWS. Here is the setup:
┌─────────────────────────────────────────┐
│ AWS Account │
│ │
│ ┌──────────┐ ┌─────────────────┐ │
│ │ EC2 │ │ RDS │ │
│ │ (Ubuntu) │─────▶│ (PostgreSQL) │ │
│ │ Nginx │ │ Private subnet │ │
│ │ Gunicorn │ └─────────────────┘ │
│ │ Django │ │
│ └────┬─────┘ ┌─────────────────┐ │
│ │ │ S3 Bucket │ │
│ └───────────▶│ (ID photos) │ │
│ └─────────────────┘ │
└─────────────────────────────────────────┘
- OS: Ubuntu 22.04 LTS
- Instance type: t3.micro (or t3.small for production load)
- Security group: Allows inbound HTTP (80), HTTPS (443), and SSH (22) only
- Nginx sits in front and proxies requests to Gunicorn on
localhost:8000 - Gunicorn runs as a
systemdservice so it restarts automatically on crash or reboot
Nginx config (simplified):
server {
listen 80;
server_name your-domain.com;
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /media/ {
# Serve directly from S3 or local media folder
proxy_pass http://127.0.0.1:8000;
}
}Gunicorn systemd service (/etc/systemd/system/gunicorn.service):
[Unit]
Description=Gunicorn daemon for Django
After=network.target
[Service]
User=ubuntu
WorkingDirectory=/home/ubuntu/id-portal-backend
ExecStart=/home/ubuntu/id-portal-backend/venv/bin/gunicorn \
--workers 3 \
--bind 127.0.0.1:8000 \
core.wsgi:application
[Install]
WantedBy=multi-user.target- Engine: PostgreSQL 15
- Deployment: Single-AZ (upgrade to Multi-AZ for production)
- Access: EC2 connects to RDS via a private VPC endpoint — the database is not exposed to the internet
- Backups: Automated daily snapshots enabled (7-day retention)
- Stores all uploaded passport photos
- Django uses
django-storageswith theS3Boto3Storagebackend to handle uploads automatically - Bucket has public read blocked — photos are accessed via presigned URLs or served through the Django API
- CORS is configured to allow uploads from your frontend domain
The frontend is a Next.js 14 application using the App Router. All pages are client components that talk to the Django API over HTTP.
app/
├── page.tsx ← Public homepage
├── auth/
│ ├── login/page.tsx ← Login form
│ └── register/page.tsx ← Registration form
├── student/
│ └── replacement/
│ ├── page.tsx ← Student's request list
│ └── new/page.tsx ← New replacement request form
└── admin/
├── dashboard/page.tsx ← Admin overview
└── replacements/
├── page.tsx ← All requests list
└── [id]/page.tsx ← Review a single request
There is no global state library (Redux, Zustand, etc.). Auth state is managed through a custom AuthContext:
AuthContext (React Context)
├── user — the logged-in user object (or null)
├── loading — true while rehydrating from localStorage
├── login() — calls API, stores token + user in localStorage
├── logout() — clears localStorage, resets user to null
└── register() — calls API to create a new student account
On every page load, AuthContext reads the token from localStorage and restores the session. The loading flag prevents route guards from firing before this rehydration is complete, which stops the "redirected to login on refresh" problem.
A shared Axios instance (lib/axios.ts) is configured with:
baseURLpointing to the Django API- A request interceptor that automatically attaches the JWT token from
localStorageto every request header
// lib/axios.ts
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});Both the Login and Register forms use client-side validation before any API call is made:
- Required field checks
- Email format (regex)
- Student number format (regex)
- Password minimum length, uppercase, and number requirements
- Password confirmation match
- Errors appear field-by-field on blur, not all at once on submit
1. Student visits /auth/login
2. Submits email + password
3. Django validates credentials → returns { token, user }
4. Frontend stores token in localStorage
5. AuthContext sets user in React state
6. Router pushes to /student/replacement
--- On page refresh ---
7. AuthContext useEffect reads token from localStorage
8. Sets user back into React state
9. loading becomes false
10. Route guard sees user → stays on protected page
- Python 3.11+
- PostgreSQL running locally (or use SQLite for development)
- An AWS account (for S3; can use local media storage for dev)
# 1. Clone the repository
git clone https://github.com/your-username/id-portal-backend.git
cd id-portal-backend
# 2. Create and activate a virtual environment
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 3. Install dependencies
pip install -r requirements.txt
# 4. Copy the environment file and fill in your values
cp .env.example .env
# 5. Run database migrations
python manage.py migrate
# 6. Create a superuser (this becomes your first admin)
python manage.py createsuperuser
# 7. Start the development server
python manage.py runserverThe API will be running at http://localhost:8000.
- Node.js 18+
- The backend API running (locally or on AWS)
# 1. Clone the repository
git clone https://github.com/your-username/id-portal-frontend.git
cd id-portal-frontend
# 2. Install dependencies
npm install
# 3. Copy the environment file and fill in your API URL
cp .env.example .env.local
# 4. Start the development server
npm run devThe app will be running at http://localhost:3000.
SECRET_KEY=your-django-secret-key
DEBUG=False
DATABASE_NAME=id_portal
DATABASE_USER=postgres
DATABASE_PASSWORD=your-db-password
DATABASE_HOST=your-rds-endpoint.amazonaws.com
DATABASE_PORT=5432
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_STORAGE_BUCKET_NAME=your-s3-bucket-name
AWS_S3_REGION_NAME=us-east-1
ALLOWED_HOSTS=your-ec2-ip,your-domain.com
CORS_ALLOWED_ORIGINS=https://your-frontend-domain.comNEXT_PUBLIC_API_URL=https://your-ec2-domain.com/api| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/auth/register/ |
Register a new student | No |
| POST | /api/auth/login/ |
Login and receive JWT token | No |
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/id_replace/ |
List own requests | Student |
| POST | /api/id_replace/ |
Submit a new request | Student |
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/admin/id_replace/ |
List all requests | Admin |
| GET | /api/admin/id_replace/:id/ |
View a single request | Admin |
| PATCH | /api/admin/id_replace/:id/approve/ |
Approve a request | Admin |
| PATCH | /api/admin/id_replace/:id/reject/ |
Reject with a reason | Admin |
id-portal-backend/
├── core/ ← Django project settings and URL root
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── accounts/ ← Custom user model, register, login
│ ├── models.py
│ ├── serializers.py
│ └── views.py
├── id_replace/ ← Replacement request logic
│ ├── models.py
│ ├── serializers.py
│ └── views.py
├── requirements.txt
└── manage.py
id-portal-frontend/
├── app/ ← Next.js App Router pages
├── components/ ← Shared UI components (Navbar, etc.)
├── context/
│ └── AuthContext.tsx ← Global auth state
├── lib/
│ └── axios.ts ← Configured Axios instance
├── types/
│ └── replace.ts ← TypeScript interfaces
└── public/ ← Static assets
- Always run
python manage.py collectstaticbefore deploying backend changes to EC2. - Restart Gunicorn after any backend code change:
sudo systemctl restart gunicorn. - Set
DEBUG=Falseand a strongSECRET_KEYin production. - Enable HTTPS on EC2 using Certbot (Let's Encrypt) — JWT tokens must not travel over plain HTTP.
- Set
CORS_ALLOWED_ORIGINSto your exact frontend domain — do not use*in production.
Built by Alexander · ga.nyaga7@gmail.com