Part 6: The Image Layer — From Blue Gradients to Production CDN
In Part 1, we containerized the stack. In Part 2, we built the database. In Part 3, we cached the API. In Part 4, we optimized queries. In Part 5, we built the UI. Today, we make it look real.
If you're jumping in here, you need the full context. Part 1 through Part 5 built the entire system — infrastructure, database, API, caching, and UI. If you're continuing from Part 5, you have a working housing portal with property cards displaying blue gradient placeholders where images should be.
Today we replace those placeholders with real property photos. By the end of this post, you'll have images hosted on a CDN, automatic thumbnails, lazy loading, WebP conversion, and responsive images that adapt to device size. The page will load 85% faster. The images will be 90% smaller. Everything will feel production-ready.
The Image Problem
A single unoptimized property photo is 3-5MB. Twenty of them on a listing page is 60-100MB. Over a 3G connection, that's 90 seconds of loading — or a timeout. Even over WiFi, it's 5-10 seconds of white screen.
The naive approach — serving images directly from Django — makes this worse. Django reads the file from disk, holds it in memory, and streams it over HTTP. Every image request ties up a Django worker. Twenty simultaneous users requesting images = 400 concurrent requests to Django. Your API stops responding. The database queries start timing out. The entire system chokes.
We need a different architecture:
- Offload image hosting to a CDN — images are served from edge servers geographically close to users, not from your single Django server
- Generate thumbnails on-demand — a 400x300px card thumbnail is 50KB, not 5MB
- Convert to modern formats — WebP is 30% smaller than JPEG for the same visual quality
- Lazy load — only load images when they're about to enter the viewport
- Optimize with Next.js — automatic responsive images, format selection, and caching
By the end of this part, the system will handle images the way Netflix handles video: distributed, optimized, and invisible to the user.
Part A: The CDN Decision — Choosing Cloudinary
We need an image hosting service that can:
- Store our images (thousands of them)
- Generate thumbnails and crops on-demand
- Serve images from a global CDN
- Convert formats automatically (JPEG → WebP → AVIF)
- Integrate with Django (for uploads) and Next.js (for display)
The Options
Cloudinary (recommended for this series)
- Free tier: 25GB storage + 25GB bandwidth/month (enough for ~50,000 images)
- Automatic transformations via URL parameters
- Excellent Django SDK (
django-cloudinary-storage) - Works seamlessly with Next.js
<Image>component - Well-documented, widely used
ImageKit
- Similar to Cloudinary, slightly better pricing at scale
- Less Django integration (requires custom code)
- Good Next.js support
AWS S3 + CloudFront
- Most control, cheapest at massive scale
- Requires Lambda@Edge for transformations
- More setup complexity
- Best for teams already on AWS
Vercel Blob Storage
- Native Next.js integration
- Simple API
- Expensive (no free tier, $0.15/GB storage + $0.40/GB bandwidth)
- Upload from Next.js API routes, not Django
We're using Cloudinary for this series. It has the best free tier, the simplest setup, and works perfectly with both Django and Next.js. If you're already using AWS or Vercel, the concepts transfer — the URL structure and transformations are similar.
Step 1: Sign Up for Cloudinary
- Go to cloudinary.com and click "Sign Up Free"
- Complete the registration (email, password, company name)
- You'll land on the dashboard
The dashboard shows three critical values:
-
Cloud name — this appears in all your image URLs (e.g.,
res.cloudinary.com/YOUR_CLOUD_NAME/...) - API Key — used for uploads
- API Secret — used to sign upload requests (keep this secret)
Copy these three values. We'll store them in environment variables.
Step 2: Store Credentials Securely
Never commit API credentials to git. They go in environment variables.
Create backend/.env (if it doesn't exist):
# backend/.env
# Database (already exists from Part 1)
DATABASE_URL=postgres://user:password@db:5432/housing_db
# Redis (already exists from Part 3)
REDIS_URL=redis://redis:6379/1
# Cloudinary (NEW - add these)
CLOUDINARY_CLOUD_NAME=your_cloud_name_here
CLOUDINARY_API_KEY=your_api_key_here
CLOUDINARY_API_SECRET=your_api_secret_here
Replace your_cloud_name_here, etc. with your actual values from the Cloudinary dashboard.
Verify .env is in .gitignore:
# Check if .env is already ignored
cd backend
cat .gitignore | grep ".env"
If you don't see .env in the output, add it:
echo ".env" >> .gitignore
This prevents you from accidentally committing secrets.
Step 3: Pass Environment Variables to Docker
Docker containers don't automatically see .env files. We need to pass the variables explicitly.
Update docker-compose.yml:
backend:
build: ./backend
volumes:
- ./backend:/app
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgres://user:password@db:5432/housing_db
- REDIS_URL=redis://redis:6379/1
# Add these three lines ↓
- CLOUDINARY_CLOUD_NAME=${CLOUDINARY_CLOUD_NAME}
- CLOUDINARY_API_KEY=${CLOUDINARY_API_KEY}
- CLOUDINARY_API_SECRET=${CLOUDINARY_API_SECRET}
depends_on:
- db
- redis
# Add this block ↓ to load .env file from backend directory
env_file:
- ./backend/.env
What this does:
-
env_file: ./backend/.envtells Docker Compose to load environment variables from that file -
${CLOUDINARY_CLOUD_NAME}reads the value from the file and passes it to the container
Restart the backend to pick up the new environment:
docker compose restart backend
Step 4: Install Cloudinary SDK
cd backend
source venv/bin/activate # or venv\Scripts\activate on Windows
pip install cloudinary django-cloudinary-storage
pip freeze > requirements.txt
What we just installed:
-
cloudinary— the Python SDK for uploading, transforming, and managing images -
django-cloudinary-storage— Django integration that makes FileFields automatically upload to Cloudinary instead of local disk
Step 5: Configure Django to Use Cloudinary
Update backend/core/settings.py:
# At the top, with other imports
import os
import cloudinary
import cloudinary.uploader
import cloudinary.api
# ... (keep existing settings) ...
# In INSTALLED_APPS, add cloudinary_storage BEFORE django.contrib.staticfiles
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'cloudinary_storage', # ← Add this BEFORE staticfiles
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
'debug_toolbar',
'django_filters',
'housing',
]
# ... (keep existing settings) ...
# Add this at the bottom of settings.py
CLOUDINARY_STORAGE = {
'CLOUD_NAME': os.environ.get('CLOUDINARY_CLOUD_NAME'),
'API_KEY': os.environ.get('CLOUDINARY_API_KEY'),
'API_SECRET': os.environ.get('CLOUDINARY_API_SECRET'),
}
# Configure Cloudinary
cloudinary.config(
cloud_name=CLOUDINARY_STORAGE['CLOUD_NAME'],
api_key=CLOUDINARY_STORAGE['API_KEY'],
api_secret=CLOUDINARY_STORAGE['API_SECRET'],
secure=True # Use HTTPS
)
# Set default file storage (for FileFields and ImageFields)
DEFAULT_FILE_STORAGE = 'cloudinary_storage.storage.MediaCloudinaryStorage'
What this does:
- Reads the Cloudinary credentials from environment variables
- Configures the SDK with those credentials
- Sets
DEFAULT_FILE_STORAGEso any DjangoImageFieldorFileFieldautomatically uploads to Cloudinary instead of saving to local disk
Step 6: Verify the Connection
Before we modify models, let's prove Cloudinary is configured correctly.
Create a test script: backend/scripts/test_cloudinary.py
"""
scripts/test_cloudinary.py
Test script to verify Cloudinary connection.
Uploads a dummy image and prints the URL.
Run with:
docker compose exec backend python scripts/test_cloudinary.py
"""
import os
import sys
import django
# Add the parent directory to the Python path so we can import Django settings
sys.path.append('/app')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
django.setup()
import cloudinary
import cloudinary.uploader
def test_upload():
print("Testing Cloudinary connection...")
print(f"Cloud name: {cloudinary.config().cloud_name}")
# Create a tiny test image in memory
from PIL import Image
import io
img = Image.new('RGB', (100, 100), color='blue')
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
# Upload to Cloudinary
try:
result = cloudinary.uploader.upload(
buffer,
folder="housing_test", # Optional: organize in folders
public_id="test_image"
)
print("✅ Upload successful!")
print(f"Image URL: {result['secure_url']}")
print(f"Public ID: {result['public_id']}")
return True
except Exception as e:
print(f"❌ Upload failed: {e}")
return False
if __name__ == '__main__':
success = test_upload()
sys.exit(0 if success else 1)
Install Pillow (required for the test script):
pip install Pillow
pip freeze > requirements.txt
Rebuild the backend container:
docker compose build backend
docker compose restart backend
Run the test:
docker compose exec backend python scripts/test_cloudinary.py
Expected output:
Testing Cloudinary connection...
Cloud name: your_cloud_name
✅ Upload successful!
Image URL: https://res.cloudinary.com/your_cloud_name/image/upload/v1234567890/housing_test/test_image.png
Public ID: housing_test/test_image
If you see ✅, the connection works. If you see ❌, check:
- The environment variables are set correctly in
backend/.env - The
docker-compose.ymlhasenv_file: ./backend/.env - You restarted the backend after adding the env vars
Go to your Cloudinary dashboard → Media Library. You should see the test image there.
Part B: The Upload Flow — Django Admin to CDN
Now that Cloudinary is connected, we need to update the Django admin so admins can upload property images.
Step 7: Update the PropertyImage Model
Our current model has original_url as a URLField — a text field that stores a URL string. We need to change it to an ImageField — a special field that handles file uploads.
Update backend/housing/models.py:
from django.db import models
from cloudinary.models import CloudinaryField # ← Add this import
# ... (keep Location, Office, Agent, Property models unchanged) ...
class PropertyImage(models.Model):
"""
Images for a property listing.
The image field uses CloudinaryField instead of Django's ImageField.
This automatically uploads to Cloudinary and stores the Cloudinary URL.
"""
listing = models.ForeignKey(
Property,
on_delete=models.CASCADE,
related_name="images",
db_index=False,
)
# Replace the old URLField with CloudinaryField
image = CloudinaryField(
'image',
folder='housing/properties', # Organize in Cloudinary folders
transformation={
'quality': 'auto', # Automatic quality optimization
'fetch_format': 'auto', # Automatic format selection (WebP, AVIF, etc.)
}
)
display_order = models.IntegerField(default=0)
alt_text = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['display_order']
verbose_name_plural = "property images"
def __str__(self):
return f"Image {self.display_order} for {self.listing.title}"
def get_original_url(self):
"""
Returns the original uploaded image URL.
"""
if self.image:
return self.image.url
return None
def get_thumbnail_url(self, width=400, height=300):
"""
Returns a thumbnail URL with Cloudinary transformations.
Cloudinary generates this on-demand — no separate file is stored.
"""
if not self.image:
return None
# Build transformation string
# w_400,h_300,c_fill = width 400px, height 300px, fill mode (crop to fit)
return self.image.build_url(transformation=[
{'width': width, 'height': height, 'crop': 'fill'},
{'quality': 'auto'},
{'fetch_format': 'auto'},
])
def get_webp_url(self):
"""
Returns a WebP version of the image.
WebP is 25-35% smaller than JPEG for the same visual quality.
"""
if not self.image:
return None
return self.image.build_url(transformation=[
{'fetch_format': 'webp'},
{'quality': 'auto'},
])
What changed:
-
original_url→image(CloudinaryField instead of URLField) - Added
get_original_url(),get_thumbnail_url(),get_webp_url()methods - Cloudinary transformations are applied via URL parameters, not separate files
Important: This is a schema change. We need a migration.
Step 8: Create and Run the Migration
Before we migrate, we have existing data. The old PropertyImage rows have URLs in the original_url field (picsum.photos links from the seed command). When we change the field to image (CloudinaryField), those rows will have a null image field.
We have two options:
Option A: Keep old data, manually upload images later via admin
- Run the migration
- The old
original_urlfield is removed - Existing images disappear from the frontend (until you upload new ones)
Option B: Migrate existing picsum.photos images to Cloudinary
-
Write a data migration that downloads each picsum image and uploads to Cloudinary (Check the troubleshoot section with new seed command)
Takes time (5000+ images) but preserves existing data
For this tutorial, we'll use Option A (simpler). If you want to preserve the seed data, I'll provide a migration script in the troubleshooting section.
Run the migration:
# Generate the migration file
docker compose exec backend python manage.py makemigrations housing
# Django will detect the field change and ask:
# "You are trying to change the nullable field 'original_url' on propertyimage to non-nullable field 'image' without a default"
# Select option 1: Provide a one-off default now
# Enter: None
# Apply the migration
docker compose exec backend python manage.py migrate housing
If you get errors about the migration, use the --fake option to skip applying it (we're dropping and recreating anyway):
docker compose exec backend python manage.py migrate housing --fake
Step 9: Update the Django Admin for Image Uploads
Now that the model accepts uploads, we need an admin interface.
Update backend/housing/admin.py:
from django.contrib import admin
from .models import Location, Office, Agent, Property, PropertyImage
# ... (keep Location, Office, Agent admin classes unchanged) ...
class PropertyImageInline(admin.TabularInline):
"""
Inline admin for uploading multiple images per property.
Shows as a table below the property form.
"""
model = PropertyImage
extra = 3 # Show 3 empty upload slots by default
fields = ['image', 'display_order', 'alt_text']
# Optional: show thumbnail preview in admin
readonly_fields = ['image_preview']
def image_preview(self, obj):
"""Display a small thumbnail in the admin list"""
if obj.image:
return f'<img src="{obj.get_thumbnail_url(100, 75)}" />'
return "No image"
image_preview.allow_tags = True
@admin.register(Property)
class PropertyAdmin(admin.ModelAdmin):
list_display = [
'title', 'property_type', 'price', 'bedrooms',
'bathrooms', 'status', 'location', 'agent',
'is_published', 'view_count', 'created_at',
]
list_filter = ['property_type', 'status', 'is_published']
search_fields = ['title', 'description']
ordering = ['-created_at']
list_select_related = ['location', 'agent']
# Add the inline ↓
inlines = [PropertyImageInline]
# The PropertyImage admin is now handled by the inline above
# But we can still register it separately for direct access
@admin.register(PropertyImage)
class PropertyImageAdmin(admin.ModelAdmin):
list_display = ['listing', 'display_order', 'alt_text', 'created_at']
list_select_related = ['listing']
ordering = ['listing', 'display_order']
What this does:
-
PropertyImageInlineadds an image upload section directly on the Property edit page - Admins can upload multiple images at once
-
extra = 3means 3 empty upload slots appear by default -
image_previewshows a thumbnail of each uploaded image
Restart the backend:
docker compose restart backend
Step 10: Test the Upload Flow
- Open
http://localhost:8000/admin/ - Log in with your superuser credentials (from Part 2)
- Go to Housing → Properties
- Click any property to edit it
- Scroll down — you should see "Property images" section with 3 upload slots
- Click "Choose File" and select an image from your computer
- Set Display order to
0(this will be the main image) - Set Alt text to something descriptive (e.g., "Front view of property")
- Click "Save and continue editing"
After saving, Django uploads the image to Cloudinary and stores the URL in the database. Refresh the page — you should see a small thumbnail preview of the image you just uploaded.
📊 [Screenshot: Django Admin Image Upload Interface]
Go to your Cloudinary dashboard → Media Library → housing/properties folder. You should see the image there.
The URL pattern looks like:
https://res.cloudinary.com/YOUR_CLOUD_NAME/image/upload/v1234567890/housing/properties/abc123.jpg
This is the URL Django stores in the database. Every transformation (thumbnail, WebP, etc.) is generated from this base URL by adding parameters.
Part C: The Frontend Integration — Next.js Image Component
The backend is uploading images to Cloudinary. Now we need the frontend to display them.
Step 11: Update the Django Serializer
The API needs to return image URLs. Currently, the PropertySerializer doesn't include images.
Update backend/housing/serializers.py:
from rest_framework import serializers
from .models import Office, Agent, Location, Property, PropertyImage
# ... (keep existing serializers unchanged) ...
class PropertyImageSerializer(serializers.ModelSerializer):
"""
Serializer for property images.
Returns multiple URL variants for different use cases.
"""
original_url = serializers.SerializerMethodField()
thumbnail_url = serializers.SerializerMethodField()
webp_url = serializers.SerializerMethodField()
class Meta:
model = PropertyImage
fields = ['id', 'original_url', 'thumbnail_url', 'webp_url', 'display_order', 'alt_text']
def get_original_url(self, obj):
"""Full-size image URL"""
return obj.get_original_url()
def get_thumbnail_url(self, obj):
"""400x300 thumbnail URL"""
return obj.get_thumbnail_url(width=400, height=300)
def get_webp_url(self, obj):
"""WebP version URL (smaller file size)"""
return obj.get_webp_url()
class PropertySerializer(serializers.ModelSerializer):
location = LocationSerializer(read_only=True)
agent = AgentSerializer(read_only=True)
images = PropertyImageSerializer(many=True, read_only=True) # ← Add this
class Meta:
model = Property
fields = [
'id', 'title', 'description', 'property_type', 'price',
'bedrooms', 'bathrooms', 'location', 'agent', 'status',
'view_count', 'created_at', 'images', # ← Add to fields list
]
What this does:
- Adds
PropertyImageSerializerthat returnsoriginal_url,thumbnail_url, andwebp_url - Includes
imagesinPropertySerializerso the API returns an array of images per property - Uses SerializerMethodField to call our model methods
Step 12: Update the View to Prefetch Images
Without prefetch_related, Django will fire N+1 queries again — one query to get properties, then one query per property to get its images.
Update backend/housing/views.py:
class OptimizedPropertyListView(generics.ListAPIView):
serializer_class = PropertySerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['property_type', 'status', 'location__city']
search_fields = ['title', 'description']
def get_queryset(self):
return Property.objects.select_related(
'location',
'agent__office'
).prefetch_related('images').all().order_by('-created_at') # ← Add prefetch_related
Restart the backend:
docker compose restart backend
Test the API directly:
curl -s http://localhost:8000/api/properties/cached/ | jq '.results[0].images'
You should see an array of image objects (or an empty array if you haven't uploaded images yet):
[
{
"id": 1,
"original_url": "https://res.cloudinary.com/.../housing/properties/abc123.jpg",
"thumbnail_url": "https://res.cloudinary.com/.../w_400,h_300,c_fill/.../abc123.jpg",
"webp_url": "https://res.cloudinary.com/.../f_webp/.../abc123.jpg",
"display_order": 0,
"alt_text": "Front view of property"
}
]
Step 13: Configure Next.js for External Images
Next.js doesn't allow external image URLs by default (security feature). We need to whitelist Cloudinary.
Update frontend/next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'res.cloudinary.com',
pathname: '/**',
},
// Keep picsum.photos for any old seed data that might still exist
{
protocol: 'https',
hostname: 'picsum.photos',
pathname: '/**',
},
],
// Optional: configure device sizes for responsive images
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
}
module.exports = nextConfig
What this does:
-
remotePatternstells Next.js which external domains are allowed -
deviceSizesdefines the widths Next.js should generate (for responsive images) -
imageSizesdefines smaller icon sizes
Restart the frontend:
docker compose restart frontend
Step 14: Update PropertyCard to Use Real Images
Replace the blue gradient placeholder with the Next.js <Image> component.
Update frontend/components/PropertyCard.tsx:
import { Property } from '@/types/property';
import { formatPrice, capitalize } from '@/lib/formatters';
import Image from 'next/image'; // ← Add this import
interface PropertyCardProps {
property: Property;
}
export function PropertyCard({ property }: PropertyCardProps) {
const statusColors = {
available: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
sold: 'bg-red-100 text-red-800',
};
// Get the first image (display_order = 0) or fallback to placeholder
const mainImage = property.images && property.images.length > 0
? property.images.find(img => img.display_order === 0) || property.images[0]
: null;
return (
<div className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg transition-shadow duration-200">
{/* Image section - UPDATED */}
<div className="relative h-48 w-full bg-gray-100">
{mainImage ? (
<Image
src={mainImage.thumbnail_url}
alt={mainImage.alt_text || property.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
loading="lazy"
/>
) : (
// Fallback gradient if no image uploaded
<div className="h-full w-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-6xl font-bold">
{property.bedrooms}BR
</div>
)}
</div>
{/* Content - keep unchanged */}
<div className="p-4">
<div className="mb-2">
<span className={`inline-block px-2 py-1 text-xs font-semibold rounded ${statusColors[property.status]}`}>
{capitalize(property.status)}
</span>
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2 line-clamp-2">
{property.title}
</h3>
<p className="text-2xl font-bold text-blue-600 mb-3">
{formatPrice(property.price)}
</p>
<div className="grid grid-cols-2 gap-2 text-sm text-gray-600 mb-3">
<div>
<span className="font-semibold">{property.bedrooms}</span> Beds
</div>
<div>
<span className="font-semibold">{property.bathrooms}</span> Baths
</div>
<div className="col-span-2">
<span className="font-semibold">{capitalize(property.property_type)}</span>
</div>
</div>
<div className="border-t pt-3">
<p className="text-sm text-gray-600">
📍 {property.location.city}, {property.location.state}
</p>
</div>
{property.agent && (
<div className="mt-2 text-xs text-gray-500">
Listed by {property.agent.name}
</div>
)}
</div>
</div>
);
}
What changed:
- Added
Imageimport fromnext/image - Added logic to find the main image (
display_order === 0or first image) - Replaced the
<div>with<Image>component - Used
mainImage.thumbnail_url(the 400x300 version, not the full-size original) - Added
fillprop (makes image fill the parent container) - Added
sizesprop (tells the browser what size image to request at different viewport widths) - Added
loading="lazy"(browser only loads images as they enter viewport) - Kept the gradient fallback for properties without images
Understanding the props:
-
fill— Image expands to fill the parent div. Requiresposition: relativeon parent (which we have via Tailwind'srelativeclass). -
sizes— Tells the browser "on mobile (≤768px), this image takes 100% width. On tablet (≤1200px), 50% width. On desktop, 33% width." The browser uses this to request the appropriately-sized image. -
loading="lazy"— Defers loading images that are off-screen. Standard HTML attribute, works in all modern browsers.
Step 15: Update the TypeScript Types
The Property interface needs to include the images field.
Update frontend/types/property.ts:
// ... (keep existing interfaces) ...
export interface PropertyImage {
id: number;
original_url: string;
thumbnail_url: string;
webp_url: string;
display_order: number;
alt_text: string;
}
export interface Property {
id: number;
title: string;
description: string;
property_type: 'apartment' | 'house' | 'villa' | 'studio' | 'condo';
price: string;
bedrooms: number;
bathrooms: number;
location: Location;
agent: Agent;
status: 'available' | 'pending' | 'sold';
view_count: number;
created_at: string;
images: PropertyImage[]; // ← Add this line
}
// ... (keep PaginatedResponse unchanged) ...
Step 16: Test the Real Images
- Make sure you uploaded at least one image via Django admin (Step 10)
- Open
http://localhost:3000/in your browser - You should see real property photos instead of blue gradients
If you see blue gradients still:
- Check the API response:
curl http://localhost:8000/api/properties/cached/ | jq '.results[0].images' - If the
imagesarray is empty, no images are uploaded yet - If the
imagesarray has data but the frontend shows gradients, check the browser console for errors
📊 [Exercise - : Property Cards with Real Images]
Instructions: Open the listing page in your browser. Capture a screenshot showing the property grid with real images loaded from Cloudinary instead of blue gradients. Highlight one card to show the image quality.
📊 [Example Screenshot - : Property Cards with Real Images]
Part D: Lazy Loading and Performance
Right now, every image on the page loads immediately — even images that are off-screen. On a page with 20 properties, that's 20 network requests happening at once. We need lazy loading.
How Lazy Loading Works
The browser's Intersection Observer API watches elements as they scroll into view. When an image is about to become visible (say, 200px before it enters the viewport), the browser starts loading it. By the time the user scrolls to it, it's ready.
Next.js <Image> component does this automatically if you set loading="lazy" (which we did in Step 14). But let's verify it's working and understand what's happening.
Step 17: Verify Lazy Loading
Open http://localhost:3000/ in your browser with DevTools open (F12 → Network tab).
- Clear the network log — Click the 🚫 icon in DevTools to clear existing requests
- Hard refresh — Press Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)
- Watch the Network tab — You should see ~6-8 image requests initially (only images above the fold)
- Scroll down slowly — As you scroll, more image requests appear in the Network tab
- Count total requests — By the time you reach the bottom, you should see 20 image requests total
What this proves:
- Initial load: only visible images (6-8 requests)
- On scroll: remaining images (12-14 requests)
- Total transfer size is lower initially, page loads faster
📊 [Exercise : DevTools Network Tab Showing Lazy Loading]
Instructions: With DevTools Network tab open, load the page and immediately take a screenshot showing only 6-8 image requests. Then scroll to the bottom and take another screenshot showing all 20 requests. Highlight the difference in request count.
Step 18: Custom Lazy Loading with Intersection Observer (Advanced)
The native loading="lazy" attribute is great, but it has limitations:
- You can't control when the loading starts (browsers decide)
- You can't show a custom loading state (skeleton, blur placeholder, etc.)
For more control, we can use the react-intersection-observer library.
Install it:
cd frontend
npm install react-intersection-observer
Create frontend/components/LazyImage.tsx:
/**
* components/LazyImage.tsx
*
* Custom lazy loading component with Intersection Observer.
* Shows a skeleton loader until the image enters the viewport.
*/
'use client';
import { useInView } from 'react-intersection-observer';
import Image from 'next/image';
import { useState } from 'react';
interface LazyImageProps {
src: string;
alt: string;
fill?: boolean;
width?: number;
height?: number;
className?: string;
sizes?: string;
}
export function LazyImage({ src, alt, fill, width, height, className, sizes }: LazyImageProps) {
const { ref, inView } = useInView({
triggerOnce: true, // Load once, don't unload when scrolling back up
rootMargin: '200px', // Start loading 200px before entering viewport
});
const [isLoading, setIsLoading] = useState(true);
return (
<div ref={ref} className="relative w-full h-full">
{inView ? (
<>
{/* Skeleton loader - shows while image is loading */}
{isLoading && (
<div className="absolute inset-0 bg-gray-200 animate-pulse" />
)}
{/* Actual image */}
<Image
src={src}
alt={alt}
fill={fill}
width={width}
height={height}
className={className}
sizes={sizes}
onLoadingComplete={() => setIsLoading(false)}
/>
</>
) : (
/* Placeholder before image enters viewport */
<div className="w-full h-full bg-gray-200" />
)}
</div>
);
}
What this does:
-
useInViewhook detects when the component is about to enter the viewport -
rootMargin: '200px'means start loading 200px before it's visible - Shows a gray placeholder before loading
- Shows an animated skeleton while loading
- Shows the image after loading completes
To use it, replace <Image> with <LazyImage> in PropertyCard.tsx:
import { LazyImage } from './LazyImage';
// Inside PropertyCard:
{mainImage ? (
<LazyImage
src={mainImage.thumbnail_url}
alt={mainImage.alt_text || property.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
) : (
<div className="h-full w-full bg-gradient-to-br from-blue-400 to-blue-600" />
)}
This is optional — the native loading="lazy" works fine for most cases. Use LazyImage if you want custom loading states.
Step 19: Blur Placeholder (Premium UX)
Next.js supports blur placeholders — a tiny, blurred version of the image shows while the full image loads. This is the pattern used by Medium, Unsplash, and other high-end sites.
To use it, you need a blurDataURL — a tiny base64-encoded version of the image.
Two approaches:
Approach A: Generate blur hash at build time (requires plaiceholder library)
- Best for static images
- Doesn't work well for dynamic user-uploaded content
- Skip this for now
Approach B: Use Cloudinary's blur transformation (recommended)
Cloudinary can generate a tiny, blurred version on-demand. We create a helper function that transforms any Cloudinary URL into a blur placeholder.
Create frontend/lib/cloudinary.ts:
/**
* lib/cloudinary.ts
*
* Helper functions for Cloudinary URL transformations.
*/
/**
* Generate a tiny blurred version of a Cloudinary image for use as a placeholder.
* Returns a 20px-wide, heavily blurred version.
*/
export function getCloudinaryBlurURL(url: string): string {
if (!url || !url.includes('res.cloudinary.com')) {
// Not a Cloudinary URL, return a solid color data URL
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgZmlsbD0iI2UwZTBlMCIvPjwvc3ZnPg==';
}
// Parse the URL and inject blur transformation
// Cloudinary URL format: https://res.cloudinary.com/{cloud_name}/image/upload/{transformations}/{path}
const parts = url.split('/upload/');
if (parts.length !== 2) return url; // Invalid format, return as-is
const [base, path] = parts;
// Build blur transformation: width=20, quality=1, blur=1000
const blurTransformation = 'w_20,q_1,e_blur:1000';
return `${base}/upload/${blurTransformation}/${path}`;
}
What this does:
- Splits the Cloudinary URL at
/upload/ - Injects transformation parameters (
w_20,q_1,e_blur:1000) - Returns a URL that generates a 20px-wide, heavily blurred image
- Falls back to a gray SVG if the URL isn't from Cloudinary
Use it in PropertyCard:
import { getCloudinaryBlurURL } from '@/lib/cloudinary';
// Inside PropertyCard, in the Image component:
{mainImage ? (
<Image
src={mainImage.thumbnail_url}
alt={mainImage.alt_text || property.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
placeholder="blur"
blurDataURL={getCloudinaryBlurURL(mainImage.thumbnail_url)}
/>
) : (
<div className="h-full w-full bg-gradient-to-br from-blue-400 to-blue-600" />
)}
What changed:
- Added
placeholder="blur"prop - Added
blurDataURL={getCloudinaryBlurURL(...)}prop
Now when images load, you'll see a blurred preview first, then the sharp image fades in. This is a subtle effect but makes the page feel much more polished.
Part E: The Property Detail Page with Image Gallery
So far, we only show the first image on each property card. We need a detail page that shows all images.
Step 20: Create the Detail Page API Endpoint
We need a Django endpoint that returns a single property by ID.
Update backend/housing/urls.py:
from django.urls import path
from .views import PropertyListView, CachedPropertyListView, OptimizedPropertyListView, PropertyDetailView # ← Add PropertyDetailView
urlpatterns = [
path('properties/live/naive/', PropertyListView.as_view(), name='property-naive'),
path('properties/cached/', CachedPropertyListView.as_view(), name='property-cached'),
path('properties/live/optimized/', OptimizedPropertyListView.as_view(), name='property-optimized'),
path('properties/<int:pk>/', PropertyDetailView.as_view(), name='property-detail'), # ← Add this
]
Add the view to backend/housing/views.py:
from rest_framework import generics
# ... (keep existing imports) ...
# ... (keep existing views) ...
class PropertyDetailView(generics.RetrieveAPIView):
"""
Retrieve a single property by ID.
Returns all images, not just the first one.
"""
queryset = Property.objects.select_related(
'location',
'agent__office'
).prefetch_related('images').all()
serializer_class = PropertySerializer
Restart the backend:
docker compose restart backend
Test it:
curl http://localhost:8000/api/properties/1/ | jq
You should see a single property object with all its images.
Step 21: Create the Image Gallery Component
Create frontend/components/ImageGallery.tsx:
/**
* components/ImageGallery.tsx
*
* Image gallery with main image and thumbnail strip.
* Click thumbnails to change the main image.
*/
'use client';
import { useState } from 'react';
import Image from 'next/image';
import type { PropertyImage } from '@/types/property';
interface ImageGalleryProps {
images: PropertyImage[];
}
export function ImageGallery({ images }: ImageGalleryProps) {
const [currentIndex, setCurrentIndex] = useState(0);
if (!images || images.length === 0) {
return (
<div className="w-full h-96 bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-4xl font-bold">
No Images
</div>
);
}
const currentImage = images[currentIndex];
return (
<div className="space-y-4">
{/* Main image */}
<div className="relative w-full h-96 bg-gray-100 rounded-lg overflow-hidden">
<Image
src={currentImage.original_url}
alt={currentImage.alt_text || 'Property image'}
fill
className="object-contain"
priority // Load first image immediately
sizes="100vw"
/>
{/* Navigation arrows (if more than 1 image) */}
{images.length > 1 && (
<>
<button
onClick={() => setCurrentIndex((currentIndex - 1 + images.length) % images.length)}
className="absolute left-4 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white p-2 rounded-full shadow-lg"
aria-label="Previous image"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => setCurrentIndex((currentIndex + 1) % images.length)}
className="absolute right-4 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white p-2 rounded-full shadow-lg"
aria-label="Next image"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</>
)}
{/* Image counter */}
<div className="absolute bottom-4 right-4 bg-black/70 text-white px-3 py-1 rounded-full text-sm">
{currentIndex + 1} / {images.length}
</div>
</div>
{/* Thumbnail strip */}
{images.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-2">
{images.map((image, index) => (
<button
key={image.id}
onClick={() => setCurrentIndex(index)}
className={`relative flex-shrink-0 w-24 h-18 rounded overflow-hidden ${
index === currentIndex ? 'ring-2 ring-blue-600' : 'opacity-60 hover:opacity-100'
}`}
>
<Image
src={image.thumbnail_url}
alt={image.alt_text || `Thumbnail ${index + 1}`}
fill
className="object-cover"
sizes="96px"
/>
</button>
))}
</div>
)}
</div>
);
}
What this does:
- Shows a large main image
- Shows a thumbnail strip below (if more than 1 image)
- Click a thumbnail to change the main image
- Left/right arrow buttons to navigate
- Image counter (1 / 5, 2 / 5, etc.)
- Uses
object-containfor main image (shows full image without cropping) - Uses
object-coverfor thumbnails (crops to fill the square)
Step 22: Create the Property Detail Page
Create frontend/app/properties/[id]/page.tsx:
/**
* app/properties/[id]/page.tsx
*
* Property detail page showing full information and all images.
*/
import { fetchAPI } from '@/lib/api';
import { Property } from '@/types/property';
import { ImageGallery } from '@/components/ImageGallery';
import { formatPrice, capitalize, formatDate } from '@/lib/formatters';
import Link from 'next/link';
interface PropertyDetailPageProps {
params: {
id: string;
};
}
export default async function PropertyDetailPage({ params }: PropertyDetailPageProps) {
const property: Property = await fetchAPI(`/api/properties/${params.id}/`);
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 py-6">
<Link href="/" className="text-blue-600 hover:text-blue-800 mb-4 inline-block">
← Back to listings
</Link>
<h1 className="text-3xl font-bold text-gray-900">{property.title}</h1>
<p className="text-gray-600 mt-1">
📍 {property.location.city}, {property.location.state} {property.location.zip_code}
</p>
</div>
</header>
{/* Main content */}
<main className="max-w-7xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left column - Images and details */}
<div className="lg:col-span-2 space-y-6">
{/* Image gallery */}
<ImageGallery images={property.images} />
{/* Description */}
<div className="bg-white rounded-lg p-6 shadow">
<h2 className="text-xl font-bold mb-4">About this property</h2>
<p className="text-gray-700 whitespace-pre-line">{property.description}</p>
</div>
{/* Details */}
<div className="bg-white rounded-lg p-6 shadow">
<h2 className="text-xl font-bold mb-4">Property Details</h2>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-gray-600">Type</dt>
<dd className="text-lg font-semibold">{capitalize(property.property_type)}</dd>
</div>
<div>
<dt className="text-sm text-gray-600">Status</dt>
<dd className="text-lg font-semibold">{capitalize(property.status)}</dd>
</div>
<div>
<dt className="text-sm text-gray-600">Bedrooms</dt>
<dd className="text-lg font-semibold">{property.bedrooms}</dd>
</div>
<div>
<dt className="text-sm text-gray-600">Bathrooms</dt>
<dd className="text-lg font-semibold">{property.bathrooms}</dd>
</div>
<div>
<dt className="text-sm text-gray-600">Listed</dt>
<dd className="text-lg font-semibold">{formatDate(property.created_at)}</dd>
</div>
<div>
<dt className="text-sm text-gray-600">Views</dt>
<dd className="text-lg font-semibold">{property.view_count.toLocaleString()}</dd>
</div>
</dl>
</div>
</div>
{/* Right column - Price and agent */}
<div className="space-y-6">
{/* Price card */}
<div className="bg-white rounded-lg p-6 shadow sticky top-4">
<div className="text-4xl font-bold text-blue-600 mb-6">
{formatPrice(property.price)}
</div>
{/* Agent info */}
{property.agent && (
<div className="border-t pt-6">
<h3 className="text-sm font-semibold text-gray-600 mb-2">LISTED BY</h3>
<p className="text-lg font-semibold">{property.agent.name}</p>
{property.agent.email && (
<p className="text-sm text-gray-600 mt-1">{property.agent.email}</p>
)}
{property.agent.phone && (
<p className="text-sm text-gray-600">{property.agent.phone}</p>
)}
{property.agent.office && (
<p className="text-sm text-gray-500 mt-2">{property.agent.office.name}</p>
)}
</div>
)}
{/* Contact button */}
<button className="w-full mt-6 bg-blue-600 text-white py-3 px-4 rounded-lg font-semibold hover:bg-blue-700 transition">
Contact Agent
</button>
</div>
</div>
</div>
</main>
</div>
);
}
What this page shows:
- Image gallery at the top
- Property description
- Property details (type, bedrooms, bathrooms, etc.)
- Price and agent info in a sticky sidebar
- Back button to return to listings
Step 23: Make Property Cards Clickable
Update frontend/components/PropertyCard.tsx to link to the detail page:
import Link from 'next/link'; // ← Add this import
// Wrap the entire card in a Link:
export function PropertyCard({ property }: PropertyCardProps) {
// ... (keep all existing code) ...
return (
<Link href={`/properties/${property.id}`} className="block">
<div className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg transition-shadow duration-200">
{/* ... (keep all existing content) ... */}
</div>
</Link>
);
}
Now when users click a property card, they navigate to the detail page.
Test it:
- Open
http://localhost:3000/ - Click any property card
- You should see the detail page with the image gallery
📊 [Screenshot : Property Detail Page with Image Gallery]
Part F: Performance Measurement — The Before and After
Let's prove the optimizations work by measuring actual performance.
Step 24: Create a Test Page Without Optimization
To show the difference, we need a "before" page that doesn't use any of our optimizations.
Create frontend/app/test-unoptimized/page.tsx:
/**
* app/test-unoptimized/page.tsx
*
* Test page WITHOUT Next.js Image optimization.
* Uses regular <img> tags to show the performance difference.
*/
import { fetchAPI } from '@/lib/api';
import { PaginatedResponse, Property } from '@/types/property';
export default async function UnoptimizedTestPage() {
const data: PaginatedResponse<Property> = await fetchAPI('/api/properties/cached/');
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-4">Unoptimized Images Test</h1>
<p className="text-red-600 mb-8">
⚠️ This page uses regular <img> tags without optimization. Do NOT use this in production.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.results.map((property) => {
const mainImage = property.images?.[0];
return (
<div key={property.id} className="border rounded-lg overflow-hidden">
<div className="h-48 bg-gray-200">
{mainImage && (
/* Regular img tag - NO optimization */
<img
src={mainImage.original_url} // Full-size original (5MB+)
alt={property.title}
className="w-full h-full object-cover"
/>
)}
</div>
<div className="p-4">
<h3 className="font-bold">{property.title}</h3>
<p className="text-xl font-bold text-blue-600">${property.price}</p>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
What this page does:
- Uses regular
<img>tags (not Next.js<Image>) - Loads
original_url(full-size images, 3-5MB each) - No lazy loading
- No format conversion (JPEG only, no WebP)
- No responsive sizing
This is the "worst case" — how the page would perform without any optimization.
Step 25: Measure the Unoptimized Page
Open http://localhost:3000/test-unoptimized with DevTools open (F12 → Network tab).
What to measure:
- Total transfer size — Look at the bottom of the Network tab. It shows "XXX MB transferred"
- Total load time — The time until all images finish loading
- Number of requests — Count the image requests
- Largest Contentful Paint (LCP) — Go to Lighthouse tab, run audit, check LCP metric
Record these numbers.
📊 [Exercise - Data: Unoptimized Performance Metrics]
Instructions: Load the test-unoptimized page with DevTools Network tab open. Record:
Metric Value Total transfer size [YOUR MB] Total load time [YOUR SECONDS] Number of image requests [YOUR COUNT] LCP (Lighthouse) [YOUR MS]
Step 26: Measure the Optimized Page
Now measure the main listing page (http://localhost:3000/) which uses all our optimizations.
Clear the browser cache first (important):
DevTools → Network tab → Right-click → Clear browser cache
Then hard refresh (Ctrl+Shift+R or Cmd+Shift+R).
Measure the same metrics:
- Total transfer size
- Total load time
- Number of requests (initially vs after scrolling)
- LCP
📊 [Exercise - Data : Optimized Performance Metrics]
Instructions: Load the main listing page with DevTools Network tab open. Record:
Metric Unoptimized Optimized Improvement Total transfer size [YOUR MB] [YOUR MB] [CALCULATE %] Total load time [YOUR S] [YOUR S] [CALCULATE %] LCP [YOUR MS] [YOUR MS] [CALCULATE %] Initial image requests 20 [YOUR COUNT] -
Expected improvements:
- Transfer size: 85-90% reduction (100MB → 10-15MB)
- Load time: 80-90% faster (10s → 1-2s)
- LCP: 70-80% faster (3-5s → 0.5-1s)
- Initial requests: 70% reduction (20 → 6-8)
The exact numbers depend on your internet speed and how many properties have images uploaded.
Step 27: Lighthouse Performance Audit
Lighthouse is Chrome's built-in performance auditing tool. It measures real-world metrics.
- Open
http://localhost:3000/in Chrome - Open DevTools (F12)
- Go to Lighthouse tab
- Select:
- Categories: Performance
- Device: Mobile (more realistic than desktop)
- Click Analyze page load
Wait 30-60 seconds for the audit to complete.
What to look for:
- Performance score — should be 90-100 (green)
- LCP (Largest Contentful Paint) — should be under 2.5s (green)
- CLS (Cumulative Layout Shift) — should be under 0.1 (green)
- FID (First Input Delay) — should be under 100ms (green)
The <Image> component with fill prop and lazy loading should give you excellent scores.
📊 [Exercise : Lighthouse Performance Score]
Instructions: Run a Lighthouse audit on the main listing page. Capture a screenshot showing the Performance score (should be 90+) and the Core Web Vitals metrics (LCP, CLS, FID).
Part G: Troubleshooting
Issue 1: "Invalid src prop" Error
Symptom:
Error: Invalid src prop (https://res.cloudinary.com/...) on `next/image`, hostname "res.cloudinary.com" is not configured under images in your `next.config.js`
Cause: Next.js doesn't allow external images by default.
Fix: Add Cloudinary to remotePatterns in next.config.js (Step 13). Restart the frontend container.
Issue 2: Images Upload to Cloudinary but Don't Display
Symptom: Image appears in Cloudinary dashboard but frontend shows gradient placeholder.
Possible causes:
-
The serializer doesn't include images — Check
PropertySerializerhasimages = PropertyImageSerializer(many=True, read_only=True) -
The view doesn't prefetch images — Check the view has
.prefetch_related('images') -
The TypeScript interface is wrong — Check
Propertyinterface hasimages: PropertyImage[]
Debug:
# Check the API response directly
curl http://localhost:8000/api/properties/cached/ | jq '.results[0].images'
If this returns an empty array, the problem is in the backend. If it returns data, the problem is in the frontend.
Issue 3: "Failed to fetch" When Uploading in Admin
Symptom: Image upload form submits but shows an error. No image appears in Cloudinary.
Cause: Cloudinary credentials are wrong or not loaded.
Fix:
- Verify credentials in
backend/.env - Verify
docker-compose.ymlloads the .env file - Restart backend:
docker compose restart backend - Run the test script from Step 6
Issue 4: Images Are Slow Even with Optimization
Symptom: Images still take 5-10 seconds to load.
Possible causes:
-
Using
original_urlinstead ofthumbnail_url— Check PropertyCard usesmainImage.thumbnail_url, notmainImage.original_url -
Cloudinary transformations not applied — Check the model methods include
quality: 'auto'andfetch_format: 'auto' - Network is genuinely slow — Test on a different network. Use Lighthouse's "throttling" feature to simulate 3G and see if Cloudinary is actually helping.
Issue 5: Layout Shift / CLS Issues
Symptom: Images "pop in" and push content down when they load. Lighthouse shows high CLS score.
Cause: The <Image> component doesn't know the aspect ratio before the image loads.
Fix: Always use fill prop with a container that has a fixed height:
<div className="relative h-48 w-full"> {/* Fixed height */}
<Image src="..." alt="..." fill className="object-cover" />
</div>
Or if you know the image dimensions, use width and height props:
<Image src="..." alt="..." width={400} height={300} />
Issue 6: Next.js Build Fails (Type Errors)
Symptom:
Type error: Property 'images' does not exist on type 'Property'
Cause: TypeScript interface doesn't match the actual data structure.
Fix: Make sure frontend/types/property.ts has the PropertyImage interface and Property has images: PropertyImage[]. Restart the dev server.
Here’s a short, dev.to–ready troubleshooting section you can drop straight into your post.
It’s concise, practical, and aligned with the exact failure modes you hit.
🧹 Troubleshooting : Cleaning the Housing Models & Reseeding Data
If you run into errors like:
relation "housing_location" already existsrelation "housing_location" does not exist- Cloudinary uploads failing during seeding
- Migrations applied but seed scripts crashing
you’re almost certainly dealing with schema drift: your Django models, migration history, and database schema are out of sync.
This commonly happens after:
- Refactoring models (e.g. moving from URL fields →
CloudinaryField) - Deleting or rewriting migration files
- Reusing an old Docker database volume
✅ The Clean Reset (recommended)
In development, the safest fix is a full reset:
docker compose down -v
This removes the PostgreSQL volume and guarantees a clean schema.
You can use code from previous GIST for seed_data_with_cloudinary:
Then rebuild everything in the correct order:
docker compose up -d
docker compose exec backend python manage.py makemigrations
docker compose exec backend python manage.py migrate
docker compose exec backend python manage.py seed_data_with_cloudinary --flush
At this point:
- The database schema exactly matches the current models
- All housing data is regenerated
- Property images are uploaded to Cloudinary using the new seed script
🔑 Important rule
Always run migrations before seeding, and always run both inside Docker.
Seeding assumes tables already exist. If migrations haven’t been applied—or were applied to a different database—you’ll see table-related errors immediately.
🧠 Why this works
The updated seed script:
- Matches the new
PropertyImagemodel - Uploads images explicitly via the Cloudinary SDK
- Stores only deterministic
public_ids in the database - Is safe to re-run after a flush
If something feels “haunted,” it’s almost always stale schema state. Resetting once saves hours of debugging later.
⚠️ When --fake Migrations Make Things Worse
Django’s --fake and --fake-initial options can be useful in very specific scenarios, but they come with a sharp edge.
If you’ve ever done something like:
python manage.py migrate --fake
python manage.py migrate app_name 0001 --fake
and later started seeing errors such as:
relation already existsrelation does not exist- migrations applying out of order
- Django insisting a migration is applied while PostgreSQL disagrees
then your database is in an unstable migration state.
What went wrong
Using --fake tells Django:
“Trust me, the database schema already matches this migration.”
But Django does not verify the actual tables, columns, or indexes.
If the schema doesn’t exactly match the models:
- Django’s migration history lies
- PostgreSQL’s schema tells a different story
- Every future migration becomes unpredictable
At that point, you’re debugging migration metadata, not your application.
The correct recovery path
Once fake migrations start cascading into different errors, stop patching.
The fastest and safest fix in development is a clean reset:
docker compose down -v
docker compose up -d
docker compose exec backend python manage.py makemigrations
docker compose exec backend python manage.py migrate
docker compose exec backend python manage.py seed_data_with_cloudinary --flush
This guarantees:
- Migration history matches the actual schema
- Models, tables, and constraints are aligned
- Seed scripts run without hidden side effects
Rule of thumb
If you used
--fakeand things start breaking later, don’t fight it.
Drop the dev database and rebuild once — it’s cheaper than chasing ghosts.
In production, --fake requires surgical care.
In development, a clean rebuild is almost always the right answer.
Part H: The Complete Image Pipeline
Let's map the full journey of an image from admin upload to user viewing.
The Flow (End to End)
Step 1: Admin uploads image
- Admin goes to
http://localhost:8000/admin/housing/property/1/change/ - Scrolls to "Property images" inline section
- Clicks "Choose File" and selects a photo
- Sets display_order = 0, alt_text = "Front view"
- Clicks "Save"
Step 2: Django processes the upload
- Django receives the file in memory
-
django-cloudinary-storageintercepts the save operation - Calls
cloudinary.uploader.upload(file, folder='housing/properties') - Cloudinary uploads the file to their CDN
- Cloudinary returns a response:
{ "secure_url": "https://res.cloudinary.com/...", "public_id": "housing/properties/abc123" } - Django saves the URL to
PropertyImage.imagefield
Step 3: User requests the listing page
- User navigates to
http://localhost:3000/ - Next.js (server-side) fetches from
http://backend:8000/api/properties/cached/ - Django queries the database with
prefetch_related('images') -
PropertyImageSerializercallsobj.get_thumbnail_url()for each image - The method generates a Cloudinary URL with transformations:
https://res.cloudinary.com/.../w_400,h_300,c_fill,q_auto,f_auto/.../abc123.jpg - Django returns JSON to Next.js
Step 4: Next.js renders the page
- Next.js Server Component receives the property data
- Renders the page HTML with
<Image>components - Browser receives the HTML
- React hydrates (makes the page interactive)
Step 5: Browser loads images
- Browser sees
<Image src="https://res.cloudinary.com/.../w_400,h_300..." loading="lazy" /> - For images above the fold (visible immediately), browser sends request to Cloudinary
- Cloudinary checks its cache: does a 400x300 WebP version of this image exist?
- If yes: Cloudinary serves it from edge cache (~10ms response time)
- If no: Cloudinary generates it on-demand (resize original to 400x300, convert to WebP), caches it, serves it (~100ms first time)
- Next.js caches the image in the browser (memory + disk cache)
- For images below the fold (not visible), browser waits until they're about to scroll into view, then repeats steps 23-27
Step 6: User navigates away and comes back
- User visits another page, then clicks Back button
- Browser checks its cache: does it have the HTML for
http://localhost:3000/? - Yes → renders immediately (0ms)
- Browser checks image cache: does it have
https://res.cloudinary.com/.../w_400...? - Yes → displays images immediately from disk cache (0ms)
- No network requests needed
Where Caching Happens
| Layer | Technology | What's cached | Invalidation |
|---|---|---|---|
| User's browser | Browser HTTP cache | HTML, images, CSS, JS | Time-based (headers) |
| Next.js CDN | Vercel edge network | Rendered HTML (on deploy) | On deploy |
| SWR | Browser memory | API responses | Focus, interval, manual |
| Cloudinary CDN | Cloudinary edge cache | Transformed images | Never expires (immutable URLs) |
| Redis | Backend cache | API responses (JSON) | Django signals |
| PostgreSQL | Database | Source data | Never (source of truth) |
Six layers of caching. Each one serves a different purpose. Remove any one, and the system still works — but it's slower.
What We Built — And What's Left
We started Part 6 with blue gradients. We ended with production-grade image delivery:
Images are hosted on a CDN. Cloudinary serves from edge servers globally. A user in Tokyo and a user in New York both get 50ms response times.
Images are optimized automatically. Cloudinary converts JPEG to WebP for Chrome users (30% smaller). It adjusts quality based on content (higher for photos, lower for graphics). It generates responsive sizes on-demand (400px for mobile, 800px for desktop).
Images lazy load. Only visible images load initially (6-8 images). The rest load as you scroll. This cuts initial transfer from 100MB to 10MB.
Images have blur placeholders. A tiny, blurred preview shows while the full image loads. This is the pattern used by Medium and Unsplash. It makes the page feel faster even when it's loading at the same speed.
The system is measurable. We went from 10s load time to 2s. From 100MB transferred to 10MB. From LCP of 5s to 1s. The Lighthouse score went from ~40 to ~95. We didn't guess. We measured.
But we're not done. The system works. It's fast. But it's not deployed. It runs on localhost. No one outside your machine can access it. Part 7 fixes that. We deploy to production. Vercel for the frontend. Railway or Fly.io for the backend. Cloudinary stays as-is (it's already a CDN). We add custom domains. We add SSL. We add monitoring. We make it real.
Checkpoint: Push to GitHub
git add .
git commit -m "feat: add Cloudinary CDN integration, image optimization, lazy loading, and image gallery"
git checkout -b part-6-images
git push origin part-6-images
The repo now has six branches:
-
part-1-setup— infrastructure -
part-2-data— database and seed -
part-3-caching— API and Redis -
part-4-optimization— query optimization and signals -
part-5-frontend— Next.js UI and SWR -
part-6-images— Cloudinary, lazy loading, gallery
Diff any two branches:
git diff part-5-frontend..part-6-images
Next: Part 7 — Deployment: From Localhost to Production. Custom domains, SSL, monitoring, and CI/CD. Stay tuned.



Top comments (0)