DEV Community

Cover image for From Zero to Cached: Building a High-Performance Housing Portal with Django, Next.js, and Redis- Part 6: Image Optimization
Ajit Kumar
Ajit Kumar

Posted on

From Zero to Cached: Building a High-Performance Housing Portal with Django, Next.js, and Redis- Part 6: Image Optimization

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:

  1. Offload image hosting to a CDN — images are served from edge servers geographically close to users, not from your single Django server
  2. Generate thumbnails on-demand — a 400x300px card thumbnail is 50KB, not 5MB
  3. Convert to modern formats — WebP is 30% smaller than JPEG for the same visual quality
  4. Lazy load — only load images when they're about to enter the viewport
  5. 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

  1. Go to cloudinary.com and click "Sign Up Free"
  2. Complete the registration (email, password, company name)
  3. 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

If you don't see .env in the output, add it:

echo ".env" >> .gitignore
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

What this does:

  • env_file: ./backend/.env tells 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

What this does:

  • Reads the Cloudinary credentials from environment variables
  • Configures the SDK with those credentials
  • Sets DEFAULT_FILE_STORAGE so any Django ImageField or FileField automatically 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)
Enter fullscreen mode Exit fullscreen mode

Install Pillow (required for the test script):

pip install Pillow
pip freeze > requirements.txt
Enter fullscreen mode Exit fullscreen mode

Rebuild the backend container:

docker compose build backend
docker compose restart backend
Enter fullscreen mode Exit fullscreen mode

Run the test:

docker compose exec backend python scripts/test_cloudinary.py
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If you see ✅, the connection works. If you see ❌, check:

  1. The environment variables are set correctly in backend/.env
  2. The docker-compose.yml has env_file: ./backend/.env
  3. 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'},
        ])
Enter fullscreen mode Exit fullscreen mode

What changed:

  • original_urlimage (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_url field 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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']
Enter fullscreen mode Exit fullscreen mode

What this does:

  • PropertyImageInline adds an image upload section directly on the Property edit page
  • Admins can upload multiple images at once
  • extra = 3 means 3 empty upload slots appear by default
  • image_preview shows a thumbnail of each uploaded image

Restart the backend:

docker compose restart backend
Enter fullscreen mode Exit fullscreen mode

Step 10: Test the Upload Flow

  1. Open http://localhost:8000/admin/
  2. Log in with your superuser credentials (from Part 2)
  3. Go to Housing → Properties
  4. Click any property to edit it
  5. Scroll down — you should see "Property images" section with 3 upload slots
  6. Click "Choose File" and select an image from your computer
  7. Set Display order to 0 (this will be the main image)
  8. Set Alt text to something descriptive (e.g., "Front view of property")
  9. 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]

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
Enter fullscreen mode Exit fullscreen mode

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
        ]
Enter fullscreen mode Exit fullscreen mode

What this does:

  • Adds PropertyImageSerializer that returns original_url, thumbnail_url, and webp_url
  • Includes images in PropertySerializer so 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
Enter fullscreen mode Exit fullscreen mode

Restart the backend:

docker compose restart backend
Enter fullscreen mode Exit fullscreen mode

Test the API directly:

curl -s http://localhost:8000/api/properties/cached/ | jq '.results[0].images'
Enter fullscreen mode Exit fullscreen mode

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"
  }
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

What this does:

  • remotePatterns tells Next.js which external domains are allowed
  • deviceSizes defines the widths Next.js should generate (for responsive images)
  • imageSizes defines smaller icon sizes

Restart the frontend:

docker compose restart frontend
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

What changed:

  • Added Image import from next/image
  • Added logic to find the main image (display_order === 0 or first image)
  • Replaced the <div> with <Image> component
  • Used mainImage.thumbnail_url (the 400x300 version, not the full-size original)
  • Added fill prop (makes image fill the parent container)
  • Added sizes prop (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. Requires position: relative on parent (which we have via Tailwind's relative class).
  • 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) ...
Enter fullscreen mode Exit fullscreen mode

Step 16: Test the Real Images

  1. Make sure you uploaded at least one image via Django admin (Step 10)
  2. Open http://localhost:3000/ in your browser
  3. 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 images array is empty, no images are uploaded yet
  • If the images array 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]

Housing home page with image


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).

  1. Clear the network log — Click the 🚫 icon in DevTools to clear existing requests
  2. Hard refresh — Press Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)
  3. Watch the Network tab — You should see ~6-8 image requests initially (only images above the fold)
  4. Scroll down slowly — As you scroll, more image requests appear in the Network tab
  5. 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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • useInView hook 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" />
)}
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

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" />
)}
Enter fullscreen mode Exit fullscreen mode

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
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Restart the backend:

docker compose restart backend
Enter fullscreen mode Exit fullscreen mode

Test it:

curl http://localhost:8000/api/properties/1/ | jq
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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-contain for main image (shows full image without cropping)
  • Uses object-cover for 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now when users click a property card, they navigate to the detail page.

Test it:

  1. Open http://localhost:3000/
  2. Click any property card
  3. You should see the detail page with the image gallery

📊 [Screenshot : Property Detail Page with Image Gallery]

Property details


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 &lt;img&gt; 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Total transfer size — Look at the bottom of the Network tab. It shows "XXX MB transferred"
  2. Total load time — The time until all images finish loading
  3. Number of requests — Count the image requests
  4. 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
Enter fullscreen mode Exit fullscreen mode

Then hard refresh (Ctrl+Shift+R or Cmd+Shift+R).

Measure the same metrics:

  1. Total transfer size
  2. Total load time
  3. Number of requests (initially vs after scrolling)
  4. 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.

  1. Open http://localhost:3000/ in Chrome
  2. Open DevTools (F12)
  3. Go to Lighthouse tab
  4. Select:
    • Categories: Performance
    • Device: Mobile (more realistic than desktop)
  5. 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`
Enter fullscreen mode Exit fullscreen mode

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:

  1. The serializer doesn't include images — Check PropertySerializer has images = PropertyImageSerializer(many=True, read_only=True)
  2. The view doesn't prefetch images — Check the view has .prefetch_related('images')
  3. The TypeScript interface is wrong — Check Property interface has images: PropertyImage[]

Debug:

# Check the API response directly
curl http://localhost:8000/api/properties/cached/ | jq '.results[0].images'
Enter fullscreen mode Exit fullscreen mode

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:

  1. Verify credentials in backend/.env
  2. Verify docker-compose.yml loads the .env file
  3. Restart backend: docker compose restart backend
  4. 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:

  1. Using original_url instead of thumbnail_url — Check PropertyCard uses mainImage.thumbnail_url, not mainImage.original_url
  2. Cloudinary transformations not applied — Check the model methods include quality: 'auto' and fetch_format: 'auto'
  3. 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>
Enter fullscreen mode Exit fullscreen mode

Or if you know the image dimensions, use width and height props:

<Image src="..." alt="..." width={400} height={300} />
Enter fullscreen mode Exit fullscreen mode

Issue 6: Next.js Build Fails (Type Errors)

Symptom:

Type error: Property 'images' does not exist on type 'Property'
Enter fullscreen mode Exit fullscreen mode

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 exists
  • relation "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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 PropertyImage model
  • 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
Enter fullscreen mode Exit fullscreen mode

and later started seeing errors such as:

  • relation already exists
  • relation 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
Enter fullscreen mode Exit fullscreen mode

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 --fake and 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

  1. Admin goes to http://localhost:8000/admin/housing/property/1/change/
  2. Scrolls to "Property images" inline section
  3. Clicks "Choose File" and selects a photo
  4. Sets display_order = 0, alt_text = "Front view"
  5. Clicks "Save"

Step 2: Django processes the upload

  1. Django receives the file in memory
  2. django-cloudinary-storage intercepts the save operation
  3. Calls cloudinary.uploader.upload(file, folder='housing/properties')
  4. Cloudinary uploads the file to their CDN
  5. Cloudinary returns a response: { "secure_url": "https://res.cloudinary.com/...", "public_id": "housing/properties/abc123" }
  6. Django saves the URL to PropertyImage.image field

Step 3: User requests the listing page

  1. User navigates to http://localhost:3000/
  2. Next.js (server-side) fetches from http://backend:8000/api/properties/cached/
  3. Django queries the database with prefetch_related('images')
  4. PropertyImageSerializer calls obj.get_thumbnail_url() for each image
  5. The method generates a Cloudinary URL with transformations: https://res.cloudinary.com/.../w_400,h_300,c_fill,q_auto,f_auto/.../abc123.jpg
  6. Django returns JSON to Next.js

Step 4: Next.js renders the page

  1. Next.js Server Component receives the property data
  2. Renders the page HTML with <Image> components
  3. Browser receives the HTML
  4. React hydrates (makes the page interactive)

Step 5: Browser loads images

  1. Browser sees <Image src="https://res.cloudinary.com/.../w_400,h_300..." loading="lazy" />
  2. For images above the fold (visible immediately), browser sends request to Cloudinary
  3. Cloudinary checks its cache: does a 400x300 WebP version of this image exist?
  4. If yes: Cloudinary serves it from edge cache (~10ms response time)
  5. If no: Cloudinary generates it on-demand (resize original to 400x300, convert to WebP), caches it, serves it (~100ms first time)
  6. Next.js caches the image in the browser (memory + disk cache)
  7. 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

  1. User visits another page, then clicks Back button
  2. Browser checks its cache: does it have the HTML for http://localhost:3000/?
  3. Yes → renders immediately (0ms)
  4. Browser checks image cache: does it have https://res.cloudinary.com/.../w_400...?
  5. Yes → displays images immediately from disk cache (0ms)
  6. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Next: Part 7 — Deployment: From Localhost to Production. Custom domains, SSL, monitoring, and CI/CD. Stay tuned.

Top comments (0)