DEV Community

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

Posted on

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

Part 5: The Frontend Layer — Bringing the API to Life

In Part 1, we built the infrastructure. In Part 2, we created the database. In Part 3, we cached the API. In Part 4, we optimized the database and made the cache smart. Today, we make it visible.


If you're jumping in here, start from the beginning. Part 1 sets up Docker. Part 2 seeds the data. Part 3 introduces Redis. Part 4 fixes the queries. If you're continuing from Part 4, you have a Django API that responds in 4ms with fresh data and 1 optimized query on cache misses.

Today we build the actual housing portal — the UI users see, the filters they interact with, and the third caching layer: the browser. By the end of this post, you'll have a working real estate listing page powered by three layers of caching working together.


What We're Building Today

We've spent four parts building the backend. Fast API. Smart cache. Optimized database. But if you open http://localhost:3000 right now, you see the default Next.js welcome page. No properties. No filters. No housing portal.

Today we change that. We're building:

  1. A property listing page — grid of cards showing title, price, location, agent
  2. Filters — search by type, city, price range
  3. Pagination — browse through all 5,000 properties
  4. Client-side caching — instant repeat visits via SWR
  5. The third caching layer — Redis (backend) + Next.js SSR + SWR (browser)

By the end, the system will be complete. A user can search for apartments in Seattle under $500k, see 20 results instantly, paginate to see more, and when they come back tomorrow, the page loads from the browser cache in 0ms.


Part A: The Foundation — Connecting Next.js to Django

The frontend container is already running from Part 1. But it's isolated. It doesn't know the Django API exists. We need to connect them — and handle the fact that "localhost" means different things depending on where the code runs.

The URL Problem

Here's the issue. When Next.js runs on the server (inside the Docker container), it needs to call the API at http://backend:8000 — that's the Docker service name from docker-compose.yml. But when Next.js runs in the browser (on your machine), it needs to call the API at http://localhost:8000 — because the browser has no access to Docker's internal network.

We need code that works in both contexts.

Step 1: Create the API Configuration

Create frontend/lib/api.ts:

/**
 * lib/api.ts
 * 
 * Centralized API configuration.
 * Handles the URL difference between server-side (Docker) and client-side (browser).
 */

// Determine the base URL based on where the code is running
const getBaseURL = () => {
  // Check if we're running on the server (Node.js) or in the browser
  if (typeof window === 'undefined') {
    // Server-side: use the Docker service name
    return 'http://backend:8000';
  }
  // Client-side: use localhost
  return 'http://localhost:8000';
};

export const API_BASE_URL = getBaseURL();

/**
 * Centralized fetch wrapper.
 * All API calls go through this function.
 */
export async function fetchAPI(endpoint: string, options?: RequestInit) {
  const url = `${API_BASE_URL}${endpoint}`;

  const response = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...options?.headers,
    },
  });

  if (!response.ok) {
    throw new Error(`API call failed: ${response.status} ${response.statusText}`);
  }

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • typeof window === 'undefined' is the standard way to detect server-side rendering in Next.js. In Node.js (the server), window doesn't exist. In the browser, it does.
  • getBaseURL() returns the correct URL for the context.
  • fetchAPI() is a wrapper around fetch that adds error handling and the correct base URL.

If you're new to TypeScript, don't worry about the type annotations (endpoint: string, options?: RequestInit) — they're just documentation that tells TypeScript what types of data to expect. The code works the same as JavaScript.

Step 2: Create TypeScript Types for the API Response

Next.js uses TypeScript by default (we chose "Yes" to TypeScript in Part 1 when running create-next-app). TypeScript needs to know what shape the API response has so it can catch errors at build time.

Create frontend/types/property.ts:

/**
 * types/property.ts
 * 
 * TypeScript interfaces that mirror the Django serializers.
 * These define the shape of the data we get from the API.
 */

export interface Location {
  id: number;
  city: string;
  state: string;
  zip_code: string;
}

export interface Office {
  id: number;
  name: string;
  city: string;
  phone: string;
}

export interface Agent {
  id: number;
  name: string;
  email: string;
  phone: string;
  office: Office;
}

export interface Property {
  id: number;
  title: string;
  description: string;
  property_type: 'apartment' | 'house' | 'villa' | 'studio' | 'condo';
  price: string;  // Django returns this as a string: "450000.00"
  bedrooms: number;
  bathrooms: number;
  location: Location;
  agent: Agent;
  status: 'available' | 'pending' | 'sold';
  view_count: number;
  created_at: string;  // ISO 8601 date string
}

export interface PaginatedResponse<T> {
  count: number;
  next: string | null;
  previous: string | null;
  results: T[];
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • Each interface defines the structure of an object. If you try to access property.invalidField, TypeScript will error at build time.
  • property_type: 'apartment' | 'house' | ... means the field can only be one of those exact strings. This is a union type.
  • PaginatedResponse<T> is a generic interface. It works with any type. PaginatedResponse<Property> means a paginated response where results is an array of Property objects.

These interfaces match the serializers we wrote in Part 3. If you change a serializer in Django, change the corresponding interface here. TypeScript will then tell you everywhere in the frontend that needs to update.

Step 3: Verify the Connection

Before we build the UI, let's prove the connection works. Create a simple test page.

Create frontend/app/test/page.tsx:

/**
 * app/test/page.tsx
 * 
 * A simple test page to verify the API connection works.
 * This is a Server Component — it fetches on the server before rendering.
 */

import { fetchAPI } from '@/lib/api';
import { PaginatedResponse, Property } from '@/types/property';

export default async function TestPage() {
  try {
    const data: PaginatedResponse<Property> = await fetchAPI('/api/properties/cached/');

    return (
      <div className="p-8">
        <h1 className="text-2xl font-bold mb-4">API Connection Test</h1>
        <p className="mb-4">
          Successfully fetched {data.results.length} properties out of {data.count} total.
        </p>
        <div className="space-y-2">
          {data.results.slice(0, 3).map((property) => (
            <div key={property.id} className="border p-4 rounded">
              <h2 className="font-bold">{property.title}</h2>
              <p>Price: ${property.price}</p>
              <p>Location: {property.location.city}, {property.location.state}</p>
            </div>
          ))}
        </div>
      </div>
    );
  } catch (error) {
    return (
      <div className="p-8">
        <h1 className="text-2xl font-bold text-red-600">Error</h1>
        <p>Failed to fetch data from API.</p>
        <pre className="mt-4 p-4 bg-gray-100 rounded">
          {error instanceof Error ? error.message : 'Unknown error'}
        </pre>
      </div>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • export default async function — this is an async Server Component. It runs on the server, fetches data, and renders HTML before sending it to the browser.
  • await fetchAPI('/api/properties/cached/') — calls our API wrapper. On the server, this uses http://backend:8000. The await syntax is standard JavaScript for handling promises.
  • try/catch — if the fetch fails (API is down, wrong URL, etc.), we show an error page instead of crashing.
  • The JSX (the HTML-like syntax) renders the properties. className is React's version of class (because class is a reserved word in JavaScript).

A note on the @/ import: Next.js configures TypeScript to treat @/ as an alias for the project root. @/lib/api means frontend/lib/api.ts. This makes imports cleaner than ../../lib/api.

Step 4: Test It

Make sure the backend is running:

docker compose ps
Enter fullscreen mode Exit fullscreen mode

You should see backend, frontend, db, and redis all in the "Up" state.

Open your browser and navigate to:

http://localhost:3000/test
Enter fullscreen mode Exit fullscreen mode

If everything is configured correctly, you should see:

API Connection Test

Successfully fetched 20 properties out of 5000 total.

[Three property cards with title, price, and location]
Enter fullscreen mode Exit fullscreen mode

Here is a screenshot:
📊 [Screenshot : Filters in Action]

Output from Test page: /test

If you see an error instead, check:

  1. Is the backend running? docker compose logs backend should show no errors.
  2. Is the API endpoint correct? The URL should be /api/properties/cached/ (with trailing slash).
  3. Is CORS configured? Check settings.pyCORS_ALLOWED_ORIGINS should include http://localhost:3000.

If you see "Failed to fetch" in the browser console, open DevTools (F12), go to the Console tab, and check the error message. It will tell you exactly what went wrong.


Part B: The UI Layer — Building the Property Card

The test page proves the connection works. Now we build the actual UI. We start with the smallest piece: a single property card.

Step 5: Create Helper Functions for Formatting

Before we build components, we need helpers to format the data. Django returns prices as "450000.00" — a string with two decimal places. Users expect $450,000. We need a formatter.

Create frontend/lib/formatters.ts:

/**
 * lib/formatters.ts
 * 
 * Utility functions for formatting data for display.
 */

/**
 * Format a price string from the API into a readable currency format.
 * Input: "450000.00"
 * Output: "$450,000"
 */
export function formatPrice(price: string): string {
  const numericPrice = parseFloat(price);
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    minimumFractionDigits: 0,  // No cents
    maximumFractionDigits: 0,
  }).format(numericPrice);
}

/**
 * Format a date string into a readable format.
 * Input: "2024-01-15T10:30:00Z"
 * Output: "Jan 15, 2024"
 */
export function formatDate(dateString: string): string {
  const date = new Date(dateString);
  return new Intl.DateTimeFormat('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
  }).format(date);
}

/**
 * Capitalize the first letter of each word.
 * Input: "apartment"
 * Output: "Apartment"
 */
export function capitalize(str: string): string {
  return str.charAt(0).toUpperCase() + str.slice(1);
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • Intl.NumberFormat and Intl.DateTimeFormat are built-in JavaScript APIs for internationalized formatting. They handle things like currency symbols, thousands separators, and date formats automatically.
  • parseFloat(price) converts the string "450000.00" into the number 450000.
  • These are pure functions — same input always produces the same output. No side effects. Easy to test.

Step 6: Create the PropertyCard Component

This is the visual representation of a single property. It's reusable — we'll map over an array of properties and render one card per property.

Create frontend/components/PropertyCard.tsx:

/**
 * components/PropertyCard.tsx
 * 
 * A card component that displays a single property listing.
 * Used in the grid on the listing page.
 */

import { Property } from '@/types/property';
import { formatPrice, capitalize } from '@/lib/formatters';

interface PropertyCardProps {
  property: Property;
}

export function PropertyCard({ property }: PropertyCardProps) {
  // Determine badge color based on status
  const statusColors = {
    available: 'bg-green-100 text-green-800',
    pending: 'bg-yellow-100 text-yellow-800',
    sold: 'bg-red-100 text-red-800',
  };

  return (
    <div className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg transition-shadow duration-200">
      {/* Image placeholder - we'll add real images in Part 6 */}
      <div className="h-48 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>

      {/* Content */}
      <div className="p-4">
        {/* Status badge */}
        <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>

        {/* Title */}
        <h3 className="text-lg font-bold text-gray-900 mb-2 line-clamp-2">
          {property.title}
        </h3>

        {/* Price */}
        <p className="text-2xl font-bold text-blue-600 mb-3">
          {formatPrice(property.price)}
        </p>

        {/* Details grid */}
        <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>

        {/* Location */}
        <div className="border-t pt-3">
          <p className="text-sm text-gray-600">
            📍 {property.location.city}, {property.location.state}
          </p>
        </div>

        {/* Agent info */}
        {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 this does:

  • PropertyCardProps defines what data this component expects. It requires one prop: property, which must be a Property object.
  • The { property }: PropertyCardProps syntax is destructuring. It extracts the property field from the props object.
  • Tailwind CSS classes handle all the styling. border border-gray-200 creates a light gray border. rounded-lg rounds the corners. hover:shadow-lg adds a shadow when you hover over the card.
  • line-clamp-2 truncates the title to 2 lines and adds ... if it's longer. This keeps cards uniform height even if titles vary.
  • The image placeholder uses a blue gradient with the number of bedrooms displayed. We'll replace this with real images in Part 6.

If you're unfamiliar with Tailwind: Each className is a utility class that applies a specific CSS property. text-2xl sets font size. font-bold makes it bold. text-blue-600 makes it blue. You don't write CSS files — you compose classes directly in the JSX. It feels weird at first, then it becomes addictive.

Step 7: Create the Listing Page

Now we use the card component to build a full page.

Replace frontend/app/page.tsx (the homepage) with this:

/**
 * app/page.tsx
 * 
 * The main listing page.
 * Server Component that fetches properties and renders a grid.
 */

import { fetchAPI } from '@/lib/api';
import { PaginatedResponse, Property } from '@/types/property';
import { PropertyCard } from '@/components/PropertyCard';

export default async function HomePage() {
  const data: PaginatedResponse<Property> = await fetchAPI('/api/properties/cached/');

  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">
          <h1 className="text-3xl font-bold text-gray-900">
            Housing Portal
          </h1>
          <p className="text-gray-600 mt-1">
            {data.count.toLocaleString()} properties available
          </p>
        </div>
      </header>

      {/* Main content */}
      <main className="max-w-7xl mx-auto px-4 py-8">
        {/* Grid of property cards */}
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          {data.results.map((property) => (
            <PropertyCard key={property.id} property={property} />
          ))}
        </div>

        {/* Pagination info */}
        <div className="mt-8 text-center text-gray-600">
          Showing {data.results.length} of {data.count} properties
        </div>
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • This replaces the default Next.js welcome page with our actual housing portal.
  • grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 creates a responsive grid: 1 column on mobile, 2 on tablets, 3 on desktop. Tailwind's responsive prefixes (md:, lg:) handle this automatically.
  • data.results.map((property) => ...) is the standard React pattern for rendering lists. It loops over the array and returns a component for each item.
  • key={property.id} is required by React. It helps React track which items changed when the list updates.

Step 8: Verify the Listing Page

Open your browser and navigate to:

http://localhost:3000/
Enter fullscreen mode Exit fullscreen mode

You should see:

  • A header showing "Housing Portal" and the total count (5,000 properties)
  • A grid of 20 property cards
  • Each card shows a blue gradient placeholder, the title, price, beds/baths, location, and agent name

📊 [Screenshot: The Listing Page]

Screenshot: The Listing Page

If you don't see cards, open the browser console (F12 → Console tab). Look for errors. Common issues:

  • "Cannot find module '@/components/PropertyCard'" — You created the file in the wrong location. It should be frontend/components/PropertyCard.tsx (no app/ prefix for components).
  • "property.location is undefined" — The API response doesn't match the TypeScript interface. Check the Django serializer — make sure it includes nested location and agent fields.
  • The page is blank but no errors — The server-side fetch might have failed silently. Check docker compose logs frontend for errors.

Part C: The Filter Bar — Interactivity

The listing page works, but it shows the same 20 properties every time. We need filters so users can search for specific types of properties in specific cities.

Filters are interactive. They use React state. That means they must be Client Components, not Server Components.

A Quick Primer on Server vs Client Components

Next.js 14+ uses a new model where components are Server Components by default. Server Components:

  • Run only on the server
  • Can await data fetches directly
  • Cannot use React state or event handlers (onClick, onChange, etc.)
  • Result in smaller JavaScript bundles (they don't ship to the browser)

Client Components:

  • Run on both server (for initial HTML) and browser (for interactivity)
  • Can use React state and event handlers
  • Must be marked with 'use client' directive at the top of the file

The pattern: Server Components fetch data. Client Components handle interactivity. Server Components can import and render Client Components. Client Components can receive data from Server Components as props.

Step 9: Create the FilterBar Component

Create frontend/components/FilterBar.tsx:

/**
 * components/FilterBar.tsx
 * 
 * A client component that renders filter inputs.
 * Updates URL query parameters when filters change.
 */

'use client';

import { useRouter, useSearchParams } from 'next/navigation';
import { useState, useEffect } from 'react';

export function FilterBar() {
  const router = useRouter();
  const searchParams = useSearchParams();

  // Initialize state from URL parameters
  const [propertyType, setPropertyType] = useState(searchParams.get('type') || '');
  const [city, setCity] = useState(searchParams.get('city') || '');
  const [minPrice, setMinPrice] = useState(searchParams.get('min_price') || '');
  const [maxPrice, setMaxPrice] = useState(searchParams.get('max_price') || '');

  // Update URL when filters change
  useEffect(() => {
    const params = new URLSearchParams();

    if (propertyType) params.set('type', propertyType);
    if (city) params.set('city', city);
    if (minPrice) params.set('min_price', minPrice);
    if (maxPrice) params.set('max_price', maxPrice);

    // Push to URL without reloading the page
    const queryString = params.toString();
    router.push(queryString ? `/?${queryString}` : '/');
  }, [propertyType, city, minPrice, maxPrice, router]);

  return (
    <div className="bg-white border border-gray-200 rounded-lg p-4 mb-6">
      <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
        {/* Property Type Filter */}
        <div>
          <label htmlFor="type" className="block text-sm font-medium text-gray-700 mb-1">
            Property Type
          </label>
          <select
            id="type"
            value={propertyType}
            onChange={(e) => setPropertyType(e.target.value)}
            className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
          >
            <option value="">All Types</option>
            <option value="apartment">Apartment</option>
            <option value="house">House</option>
            <option value="villa">Villa</option>
            <option value="studio">Studio</option>
            <option value="condo">Condo</option>
          </select>
        </div>

        {/* City Filter */}
        <div>
          <label htmlFor="city" className="block text-sm font-medium text-gray-700 mb-1">
            City
          </label>
          <select
            id="city"
            value={city}
            onChange={(e) => setCity(e.target.value)}
            className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
          >
            <option value="">All Cities</option>
            <option value="Seattle">Seattle</option>
            <option value="Portland">Portland</option>
            <option value="San Francisco">San Francisco</option>
            <option value="Los Angeles">Los Angeles</option>
            <option value="Austin">Austin</option>
            <option value="Denver">Denver</option>
            <option value="Chicago">Chicago</option>
            <option value="Miami">Miami</option>
            <option value="New York">New York</option>
            <option value="Boston">Boston</option>
          </select>
        </div>

        {/* Min Price Filter */}
        <div>
          <label htmlFor="min-price" className="block text-sm font-medium text-gray-700 mb-1">
            Min Price
          </label>
          <input
            id="min-price"
            type="number"
            value={minPrice}
            onChange={(e) => setMinPrice(e.target.value)}
            placeholder="No minimum"
            className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>

        {/* Max Price Filter */}
        <div>
          <label htmlFor="max-price" className="block text-sm font-medium text-gray-700 mb-1">
            Max Price
          </label>
          <input
            id="max-price"
            type="number"
            value={maxPrice}
            onChange={(e) => setMaxPrice(e.target.value)}
            placeholder="No maximum"
            className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>
      </div>

      {/* Clear filters button */}
      {(propertyType || city || minPrice || maxPrice) && (
        <button
          onClick={() => {
            setPropertyType('');
            setCity('');
            setMinPrice('');
            setMaxPrice('');
          }}
          className="mt-4 text-sm text-blue-600 hover:text-blue-800 font-medium"
        >
          Clear all filters
        </button>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • 'use client' at the top marks this as a Client Component.
  • useRouter and useSearchParams are Next.js hooks for reading and updating the URL.
  • useState is the React hook for component state. When you call setPropertyType('apartment'), React re-renders the component with the new value.
  • useEffect runs after every render. The dependency array [propertyType, city, ...] means "run this effect whenever any of these values change."
  • router.push() updates the URL without a full page reload. This is client-side navigation.
  • The conditional render {(propertyType || city || ...) && <button>} means "only show the clear button if at least one filter is active."

If you're new to React: useState and useEffect are the two most important hooks. useState holds data that can change. useEffect runs side effects (like updating the URL) when that data changes.

Step 10: Update the Listing Page to Use Filters

Now we need to make the Server Component read the URL params and pass them to the API.

Update frontend/app/page.tsx:

/**
 * app/page.tsx
 * 
 * Updated to support filtering via URL query parameters.
 */

import { fetchAPI } from '@/lib/api';
import { PaginatedResponse, Property } from '@/types/property';
import { PropertyCard } from '@/components/PropertyCard';
import { FilterBar } from '@/components/FilterBar';

interface HomePageProps {
  searchParams: {
    type?: string;
    city?: string;
    min_price?: string;
    max_price?: string;
    page?: string;
  };
}

export default async function HomePage({ searchParams }: HomePageProps) {
  // Build the API URL with query parameters
  const params = new URLSearchParams();

  if (searchParams.type) params.set('property_type', searchParams.type);
  if (searchParams.city) params.set('location__city', searchParams.city);
  if (searchParams.min_price) params.set('price__gte', searchParams.min_price);
  if (searchParams.max_price) params.set('price__lte', searchParams.max_price);
  if (searchParams.page) params.set('page', searchParams.page);

  const queryString = params.toString();
  const endpoint = `/api/properties/cached/${queryString ? `?${queryString}` : ''}`;

  const data: PaginatedResponse<Property> = await fetchAPI(endpoint);

  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">
          <h1 className="text-3xl font-bold text-gray-900">
            Housing Portal
          </h1>
          <p className="text-gray-600 mt-1">
            {data.count.toLocaleString()} properties available
          </p>
        </div>
      </header>

      {/* Main content */}
      <main className="max-w-7xl mx-auto px-4 py-8">
        {/* Filter bar */}
        <FilterBar />

        {/* Results count */}
        {data.count === 0 ? (
          <div className="text-center py-12">
            <p className="text-gray-600 text-lg">No properties match your filters.</p>
            <p className="text-gray-500 text-sm mt-2">Try adjusting your search criteria.</p>
          </div>
        ) : (
          <>
            {/* Grid of property cards */}
            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
              {data.results.map((property) => (
                <PropertyCard key={property.id} property={property} />
              ))}
            </div>

            {/* Pagination info */}
            <div className="mt-8 text-center text-gray-600">
              Showing {data.results.length} of {data.count} properties
            </div>
          </>
        )}
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

What changed:

  • Added searchParams prop. Next.js automatically passes URL query parameters to page components as searchParams.
  • location__city uses Django's field lookup syntax. location__city=Seattle filters on the city field of the related location object.
  • price__gte means "price greater than or equal to" (gte = greater than or equal). price__lte is "less than or equal to."
  • Added a "no results" message when data.count === 0.

Important: Django REST Framework needs to be configured to support these filters. We need to add a filter backend.

Step 11: Add Filtering to the Django API

The frontend is sending filter parameters. The backend needs to understand them.

Update housing/views.py:

"""
housing/views.py

Updated to support filtering.
"""

from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from rest_framework import generics
from rest_framework.filters import SearchFilter
from django_filters.rest_framework import DjangoFilterBackend  # ← New import
from .models import Property
from .serializers import PropertySerializer


class PropertyListView(generics.ListAPIView):
    """
    Naive baseline. No caching. No optimization.
    """
    queryset = Property.objects.all().order_by('-created_at')
    serializer_class = PropertySerializer
    filter_backends = [DjangoFilterBackend, SearchFilter]  # ← Add this
    filterset_fields = ['property_type', 'status', 'location__city']  # ← Add this
    search_fields = ['title', 'description']  # ← Add this


class CachedPropertyListView(PropertyListView):
    """
    Cached version.
    """
    @method_decorator(cache_page(60))
    def dispatch(self, *args, **kwargs):
        return super().dispatch(*args, **kwargs)


class OptimizedPropertyListView(generics.ListAPIView):
    """
    Optimized queryset.
    """
    serializer_class = PropertySerializer
    filter_backends = [DjangoFilterBackend, SearchFilter]  # ← Add this
    filterset_fields = ['property_type', 'status', 'location__city']  # ← Add this
    search_fields = ['title', 'description']  # ← Add this

    def get_queryset(self):
        return Property.objects.select_related(
            'location',
            'agent__office'
        ).all().order_by('-created_at')
Enter fullscreen mode Exit fullscreen mode

Install the django-filter package:

cd backend
source venv/bin/activate
pip install django-filter
pip freeze > requirements.txt
Enter fullscreen mode Exit fullscreen mode

Add it to INSTALLED_APPS in settings.py:

INSTALLED_APPS = [
    # ...existing apps...
    'django_filters',  # ← Add this
    'housing',
]
Enter fullscreen mode Exit fullscreen mode

Restart the backend:

docker compose restart backend
Enter fullscreen mode Exit fullscreen mode

Step 12: Test the Filters

Open http://localhost:3000/ in your browser.

  1. Select "Apartment" from the Property Type dropdown. The URL changes to /?type=apartment. The grid updates to show only apartments.
  2. Select "Seattle" from the City dropdown. The URL changes to /?type=apartment&city=Seattle. The grid updates again.
  3. Enter 200000 in Min Price and 500000 in Max Price. The URL updates. The grid shows only apartments in Seattle between $200k and $500k.
  4. Click "Clear all filters". The URL resets to /. The grid shows all properties again.

📊 [Screenshot: Filters in Action]

Screenshot: Filters in Action


If filters don't work:

  • URL changes but results don't update — The backend isn't reading the filter parameters. Check that django-filter is installed and added to INSTALLED_APPS.
  • "Unknown field: location__city" — The field lookup syntax is wrong. Check the Django model — location must be a ForeignKey, and the Location model must have a city field.
  • Filters work but cache doesn't — The cache key includes the full URL. Each filter combination creates a different cache key. That's correct behavior.

Part D: Client-Side Caching with SWR

The listing page works. Filters work. But every navigation re-fetches from the server. If a user loads the homepage, clicks a property (we haven't built that yet, but imagine), then hits the browser back button, the homepage re-fetches. That's slow.

We need client-side caching. The browser should remember data it already fetched and show it instantly on repeat visits.

What is SWR?

SWR (stale-while-revalidate) is a React hook library by Vercel (the company behind Next.js). The name describes its strategy:

  1. Stale: Show cached data immediately (even if it might be slightly outdated)
  2. While: While showing the cached data, fetch fresh data in the background
  3. Revalidate: When the fresh data arrives, update the UI if anything changed

The user sees instant results. The app stays fresh. It's the best of both worlds.

Step 13: Install SWR

cd frontend
npm install swr
Enter fullscreen mode Exit fullscreen mode

Wait for the install to finish. You'll see added 1 package in the output.

Step 14: Create the SWR Configuration

SWR needs a fetcher function — a function that knows how to fetch data. We already have one: fetchAPI from lib/api.ts. But SWR expects a slightly different signature.

Create frontend/lib/fetcher.ts:

/**
 * lib/fetcher.ts
 * 
 * Fetcher function for SWR.
 * SWR calls this function with a URL and expects a promise that resolves to data.
 */

import { API_BASE_URL } from './api';

export async function fetcher(endpoint: string) {
  const url = `${API_BASE_URL}${endpoint}`;
  const response = await fetch(url);

  if (!response.ok) {
    const error = new Error('An error occurred while fetching the data.');
    // Attach extra info to the error object
    throw error;
  }

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • Takes an endpoint (e.g., /api/properties/cached/) and returns the JSON response.
  • Throws an error if the request fails. SWR catches this and passes it to your component as the error value.

Step 15: Create the SWR Provider

SWR has global configuration options. We set them in a provider component that wraps the entire app.

Create frontend/app/providers.tsx:

/**
 * app/providers.tsx
 * 
 * Client component that wraps the app with SWR configuration.
 */

'use client';

import { SWRConfig } from 'swr';
import { fetcher } from '@/lib/fetcher';
import { ReactNode } from 'react';

interface ProvidersProps {
  children: ReactNode;
}

export function Providers({ children }: ProvidersProps) {
  return (
    <SWRConfig
      value={{
        fetcher,  // Default fetcher for all useSWR calls
        revalidateOnFocus: false,  // Don't refetch when user focuses the tab
        revalidateOnReconnect: true,  // Refetch when internet reconnects
        dedupingInterval: 2000,  // Don't refetch same endpoint within 2 seconds
      }}
    >
      {children}
    </SWRConfig>
  );
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • SWRConfig sets default options for all useSWR hooks in the app.
  • revalidateOnFocus: false — by default, SWR refetches data whenever the user focuses the browser tab. That's aggressive. We turn it off.
  • dedupingInterval: 2000 — if you call useSWR('/api/properties/') twice within 2 seconds, SWR only makes one network request and shares the result.

Step 16: Wrap the App in the Provider

Update frontend/app/layout.tsx:

/**
 * app/layout.tsx
 * 
 * The root layout that wraps all pages.
 */

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'Housing Portal',
  description: 'Find your perfect home',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

What changed:

  • Wrapped {children} in <Providers>. Now every page in the app has access to SWR configuration.

Step 17: Convert the Listing Page to Use SWR

This is the big change. We're converting from a Server Component (fetches on every request) to a Client Component (fetches once, caches in browser).

Replace frontend/app/page.tsx with this:

/**
 * app/page.tsx
 * 
 * Client Component using SWR for data fetching and caching.
 */

'use client';

import useSWR from 'swr';
import { useSearchParams } from 'next/navigation';
import { PaginatedResponse, Property } from '@/types/property';
import { PropertyCard } from '@/components/PropertyCard';
import { FilterBar } from '@/components/FilterBar';

export default function HomePage() {
  const searchParams = useSearchParams();

  // Build the endpoint URL with current filters
  const params = new URLSearchParams();
  const type = searchParams.get('type');
  const city = searchParams.get('city');
  const minPrice = searchParams.get('min_price');
  const maxPrice = searchParams.get('max_price');
  const page = searchParams.get('page');

  if (type) params.set('property_type', type);
  if (city) params.set('location__city', city);
  if (minPrice) params.set('price__gte', minPrice);
  if (maxPrice) params.set('price__lte', maxPrice);
  if (page) params.set('page', page);

  const queryString = params.toString();
  const endpoint = `/api/properties/cached/${queryString ? `?${queryString}` : ''}`;

  // SWR hook - fetches data and handles caching
  const { data, error, isLoading } = useSWR<PaginatedResponse<Property>>(endpoint);

  // Loading state
  if (isLoading) {
    return (
      <div className="min-h-screen bg-gray-50 flex items-center justify-center">
        <div className="text-center">
          <div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
          <p className="mt-4 text-gray-600">Loading properties...</p>
        </div>
      </div>
    );
  }

  // Error state
  if (error) {
    return (
      <div className="min-h-screen bg-gray-50 flex items-center justify-center">
        <div className="text-center">
          <p className="text-red-600 text-lg font-semibold">Failed to load properties</p>
          <p className="text-gray-600 mt-2">Please try again later.</p>
        </div>
      </div>
    );
  }

  // Data loaded successfully
  if (!data) return null;

  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">
          <h1 className="text-3xl font-bold text-gray-900">
            Housing Portal
          </h1>
          <p className="text-gray-600 mt-1">
            {data.count.toLocaleString()} properties available
          </p>
        </div>
      </header>

      {/* Main content */}
      <main className="max-w-7xl mx-auto px-4 py-8">
        {/* Filter bar */}
        <FilterBar />

        {/* Results */}
        {data.count === 0 ? (
          <div className="text-center py-12">
            <p className="text-gray-600 text-lg">No properties match your filters.</p>
            <p className="text-gray-500 text-sm mt-2">Try adjusting your search criteria.</p>
          </div>
        ) : (
          <>
            {/* Grid of property cards */}
            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
              {data.results.map((property) => (
                <PropertyCard key={property.id} property={property} />
              ))}
            </div>

            {/* Pagination info */}
            <div className="mt-8 text-center text-gray-600">
              Showing {data.results.length} of {data.count} properties
            </div>
          </>
        )}
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

What changed:

  • Added 'use client' at the top. This is now a Client Component.
  • Replaced await fetchAPI() with const { data, error, isLoading } = useSWR(endpoint).
  • Added explicit loading and error states. Server Components handle this automatically. Client Components need to handle it manually.
  • The rest of the JSX is identical.

How SWR works:

  • First visit: isLoading is true. Shows spinner. Fetches data. When data arrives, isLoading becomes false, data has the response. Renders the grid.
  • Second visit (same URL): SWR checks its cache. Data is there. isLoading is false immediately. Shows grid instantly. No network request.
  • Revalidation: After showing cached data, SWR refetches in the background (based on revalidateOnFocus, refreshInterval, etc.). If the data changed, it updates.

Step 18: Test the Client-Side Cache

Open http://localhost:3000/ in your browser.

  1. First load: You'll see the loading spinner for a moment, then the property grid appears.
  2. Open DevTools: Press F12. Go to the Network tab.
  3. Navigate away and back: Click the URL bar, type http://localhost:3000/test, press Enter. Then click Back.
  4. Watch the Network tab: When you go back to the homepage, you'll see no new request to /api/properties/cached/. The data came from SWR's cache.
  5. The grid appears instantly. No spinner. No delay. The browser remembered.

📊 [practice : Network Tab - SWR Cache Hit]

Instructions: With DevTools Network tab open, load the homepage, navigate to /test, then click the browser Back button. Check the Network tab showing no new API request when returning to the homepage. This proves SWR is caching.


To force SWR to revalidate:

  • Wait 60 seconds (the cache_page TTL on the backend). SWR will eventually refetch.
  • Focus a different browser tab, then focus this one again (if revalidateOnFocus was true).
  • Call mutate() manually (we'll add this later for the refresh button).

Part E: Pagination

Right now, the page shows 20 properties. There are 5,000 in the database. We need pagination.

Django REST Framework already paginates the response. The next and previous fields in the API response tell us the URLs for the next/previous pages. We just need to read them and create buttons.

Step 19: Create the Pagination Component

Create frontend/components/Pagination.tsx:

/**
 * components/Pagination.tsx
 * 
 * Pagination controls for browsing through paginated results.
 */

'use client';

import { useRouter, useSearchParams } from 'next/navigation';

interface PaginationProps {
  count: number;
  next: string | null;
  previous: string | null;
}

export function Pagination({ count, next, previous }: PaginationProps) {
  const router = useRouter();
  const searchParams = useSearchParams();
  const currentPage = parseInt(searchParams.get('page') || '1');
  const pageSize = 20;  // DRF default
  const totalPages = Math.ceil(count / pageSize);

  const goToPage = (page: number) => {
    const params = new URLSearchParams(searchParams.toString());
    params.set('page', page.toString());
    router.push(`/?${params.toString()}`);
  };

  if (totalPages <= 1) {
    // Don't show pagination if there's only one page
    return null;
  }

  return (
    <div className="mt-8 flex items-center justify-center gap-2">
      {/* Previous button */}
      <button
        onClick={() => goToPage(currentPage - 1)}
        disabled={!previous}
        className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        Previous
      </button>

      {/* Page numbers */}
      <div className="flex items-center gap-2">
        {/* First page */}
        {currentPage > 2 && (
          <>
            <button
              onClick={() => goToPage(1)}
              className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50"
            >
              1
            </button>
            {currentPage > 3 && <span className="text-gray-500">...</span>}
          </>
        )}

        {/* Previous page number */}
        {currentPage > 1 && (
          <button
            onClick={() => goToPage(currentPage - 1)}
            className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50"
          >
            {currentPage - 1}
          </button>
        )}

        {/* Current page (highlighted) */}
        <button
          disabled
          className="px-4 py-2 border-2 border-blue-600 rounded-md text-blue-600 bg-blue-50 font-semibold"
        >
          {currentPage}
        </button>

        {/* Next page number */}
        {currentPage < totalPages && (
          <button
            onClick={() => goToPage(currentPage + 1)}
            className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50"
          >
            {currentPage + 1}
          </button>
        )}

        {/* Last page */}
        {currentPage < totalPages - 1 && (
          <>
            {currentPage < totalPages - 2 && <span className="text-gray-500">...</span>}
            <button
              onClick={() => goToPage(totalPages)}
              className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50"
            >
              {totalPages}
            </button>
          </>
        )}
      </div>

      {/* Next button */}
      <button
        onClick={() => goToPage(currentPage + 1)}
        disabled={!next}
        className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        Next
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • Shows Previous/Next buttons (disabled if you're on the first/last page).
  • Shows the current page number, one page before, one page after.
  • Shows page 1 and the last page if you're far from them (with ... in between).
  • Updates the URL with ?page=2 when you click.

Step 20: Add Pagination to the Listing Page

Update frontend/app/page.tsx to include the Pagination component:

// ... (keep all the existing imports and code)
import { Pagination } from '@/components/Pagination';  // ← Add this import

export default function HomePage() {
  // ... (keep all the existing useSWR and loading/error state code)

  return (
    <div className="min-h-screen bg-gray-50">
      <header>...</header>

      <main className="max-w-7xl mx-auto px-4 py-8">
        <FilterBar />

        {data.count === 0 ? (
          <div>...</div>
        ) : (
          <>
            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
              {data.results.map((property) => (
                <PropertyCard key={property.id} property={property} />
              ))}
            </div>

            {/* Pagination - ADD THIS */}
            <Pagination count={data.count} next={data.next} previous={data.previous} />

            <div className="mt-8 text-center text-gray-600">
              Showing {data.results.length} of {data.count} properties
            </div>
          </>
        )}
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 21: Test Pagination

Open http://localhost:3000/ in your browser.

  1. Scroll to the bottom of the page. You should see pagination controls.
  2. Click "Next" or "2". The URL changes to /?page=2. The grid updates with properties 21-40.
  3. Click "3". URL becomes /?page=3. Properties 41-60 appear.

4. Click "Previous". You go back to page 2.

Each page is a different URL. Each URL is a different SWR cache key. Once you visit page 2, going back to page 2 later will be instant — SWR remembers it.


Part F: The Complete Caching Stack

We now have three caching layers working together. Let's map the complete request flow.

The Three Layers

Layer 1: Redis (Backend)

  • Technology: Redis with Django's cache_page decorator
  • Scope: Caches the full HTTP response (JSON string)
  • Invalidation: Signal-based (immediate when data changes)
  • Speed: 4ms

Layer 2: Next.js SSR (Server)

  • Technology: React Server Components
  • Scope: Renders HTML on the server before sending to browser
  • Invalidation: On every deploy (build time) or revalidation trigger
  • Speed: 20ms (if Redis is warm) or 40ms (if Redis is cold but DB is optimized)

Layer 3: SWR (Browser)

  • Technology: SWR client-side cache
  • Scope: Caches API responses in browser memory
  • Invalidation: Manual (mutate), on focus, on interval, or on mount
  • Speed: 0ms (instant from memory)

Request Flow Examples

Scenario 1: Brand new visitor, cold cache everywhere

  1. User visits http://localhost:3000/
  2. Next.js Client Component mounts
  3. useSWR('/api/properties/cached/') fires
  4. SWR checks cache: miss
  5. Fetches from http://localhost:8000/api/properties/cached/
  6. Django checks Redis: miss
  7. Django queries PostgreSQL with select_related (1 query, ~15ms)
  8. Django serializes 20 properties to JSON
  9. Django saves to Redis with 60s TTL
  10. Django returns JSON to browser
  11. SWR caches the response in browser memory
  12. React renders the property grid
  13. Total time: ~50ms (network + query + serialization)

Scenario 2: Second visitor, Redis is warm

  1. User visits http://localhost:3000/
  2. Next.js Client Component mounts
  3. useSWR('/api/properties/cached/') fires
  4. SWR checks cache: miss (different user, different browser)
  5. Fetches from Django
  6. Django checks Redis: hit (previous user warmed it)
  7. Django returns cached JSON (4ms)
  8. SWR caches in this user's browser
  9. React renders
  10. Total time: ~20ms (network + Redis)

Scenario 3: Same user returns (SWR cache is warm)

  1. User visits http://localhost:3000/ (they were here 5 minutes ago)
  2. useSWR('/api/properties/cached/') fires
  3. SWR checks cache: hit
  4. React renders immediately with cached data
  5. Total time: 0ms (instant)
  6. In the background, SWR revalidates (fetches fresh data)
  7. If data changed, React updates the UI
  8. User sees instant results, data stays fresh

Scenario 4: Admin updates a property

  1. Admin changes Property #42's price in Django admin
  2. Django's post_save signal fires
  3. Signal handler calls cache.clear()
  4. Redis cache is now empty
  5. Next SWR request: cache miss in SWR, cache miss in Redis, queries DB
  6. Redis cache repopulates
  7. SWR cache repopulates (on next revalidation)
  8. Fresh data propagates across all three layers

The Caching Matrix

Layer Hit Speed Miss Fallback Data Freshness Scope
SWR (Browser) 0ms Fetch from Django (20ms) Revalidates in background Per user, per browser
Redis (Backend) 4ms Query PostgreSQL (15ms) Immediate via signals Global, all users
PostgreSQL (Source) 15ms N/A (source of truth) Always fresh Global, all users

📊 [Practice: Your Cache Performance Matrix]

Instructions: Measure each scenario using Browser DevTools Network tab. Record the actual timings from your system. Hard refresh (Ctrl+Shift+R) to bypass SWR cache. Use Incognito mode to simulate new users. You can use following table template to fill the data to understand the achivement. Often, it goes in performance measuring of developer

Scenario Total Time Redis State SWR State DB Queries
Cold everywhere [YOUR MS] miss miss 1
Warm Redis, cold SWR [YOUR MS] hit miss 0
Warm SWR [YOUR MS] N/A hit 0


Part G: Performance Verification

Let's prove the caching works by measuring it.

Step 22: Measure First Load vs Repeat Load

Open your browser with DevTools (F12 → Network tab).

Test 1: Cold cache (hard refresh)

  1. Make sure you're on http://localhost:3000/
  2. Press Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac) to hard refresh
  3. This bypasses all browser caches
  4. In the Network tab, find the request to /api/properties/cached/
  5. Look at the "Time" column. Record this number.

Expected: 20-50ms on the first load (depends on whether Redis cache was warm).

Test 2: Warm cache (soft refresh)

  1. Press F5 to soft refresh (or click the refresh button)
  2. SWR cache might be cleared, but Redis cache is still warm
  3. Find the /api/properties/cached/ request in Network tab
  4. Record the time.

Expected: 10-25ms (slightly faster because Redis is warm).

Test 3: Navigation (SWR cache hit)

  1. Navigate to /test by typing in the URL bar
  2. Click the browser Back button
  3. The homepage loads instantly
  4. Check the Network tab — there should be no request to /api/properties/cached/

Expected: 0ms network time. The data came from SWR's cache.


📊 [Practice: Performance Comparison - DevTools Network Tab]

Instructions: Run all three tests above. You can capture three screenshots of the Network tab showing: (1) Hard refresh with ~50ms, (2) Soft refresh with ~20ms, (3) Back button with no API request.


Step 23: Measure Cache Hit Rate

Add a cache status indicator to the page so you can see when SWR is serving from cache vs fetching fresh data.

Update frontend/app/page.tsx to show the cache state:

'use client';

import useSWR from 'swr';
import { useSearchParams } from 'next/navigation';
import { PaginatedResponse, Property } from '@/types/property';
import { PropertyCard } from '@/components/PropertyCard';
import { FilterBar } from '@/components/FilterBar';
import { Pagination } from '@/components/Pagination';

export default function HomePage() {
  const searchParams = useSearchParams();

  // ... (keep all the existing endpoint building code)

  const { data, error, isLoading, isValidating } = useSWR<PaginatedResponse<Property>>(endpoint);

  // ... (keep all the existing loading/error state code)

  if (!data) return null;

  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white border-b border-gray-200">
        <div className="max-w-7xl mx-auto px-4 py-6">
          <div className="flex items-center justify-between">
            <div>
              <h1 className="text-3xl font-bold text-gray-900">Housing Portal</h1>
              <p className="text-gray-600 mt-1">
                {data.count.toLocaleString()} properties available
              </p>
            </div>

            {/* Cache status indicator - ADD THIS */}
            {isValidating && (
              <div className="flex items-center gap-2 text-sm text-gray-600">
                <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
                <span>Refreshing...</span>
              </div>
            )}
          </div>
        </div>
      </header>

      <main className="max-w-7xl mx-auto px-4 py-8">
        {/* ... (keep existing FilterBar, grid, Pagination) */}
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • isValidating is true when SWR is fetching (either initial load or revalidation in the background).
  • When SWR shows cached data and revalidates in the background, you'll see "Refreshing..." briefly in the header.
  • This proves SWR is working: instant data display (from cache) + background refresh (to keep data fresh).

Part H: Troubleshooting

Issue 1: "Cannot find module '@/lib/api'"

Symptom: Build fails or Next.js can't import the file.

Cause: The file is in the wrong location or has a typo in the filename.

Fix:

  1. Verify the file exists: frontend/lib/api.ts (NOT frontend/app/lib/api.ts)
  2. Check the import path: import { fetchAPI } from '@/lib/api' (no .ts extension)
  3. Restart the dev server: Ctrl+C in the terminal running docker compose up, then docker compose up again.

Issue 2: "useSearchParams() should be wrapped in a suspense boundary"

Symptom: Warning in console when using useSearchParams().

Cause: Next.js wants you to handle the loading state while search params are being read (for edge cases where the params aren't available immediately).

Fix: Wrap the component in a Suspense boundary. Update frontend/app/page.tsx:

import { Suspense } from 'react';

function HomePageContent() {
  // ... (move all the existing page code here)
}

export default function HomePage() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <HomePageContent />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is optional — the warning doesn't break functionality, but it's good practice.

Issue 3: SWR Shows Stale Data Forever

Symptom: You change data in Django admin. The frontend never updates, even after waiting several minutes.

Cause: SWR's cache is persistent (survives page refreshes in some browsers), and revalidation isn't happening.

Fix: Add a manual refresh button. Update the header in page.tsx:

import { mutate } from 'swr';

// ... inside the component, in the header section:

<button
  onClick={() => mutate(endpoint)}
  className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
  Refresh Data
</button>
Enter fullscreen mode Exit fullscreen mode

mutate(endpoint) tells SWR: "the data for this endpoint is stale, refetch it immediately."

Issue 4: Filters Don't Work

Symptom: You select a filter, the URL changes, but the results don't update.

Cause: The Django backend isn't reading the filter parameters.

Fix:

  1. Verify django-filter is installed: pip list | grep django-filter
  2. Verify it's in INSTALLED_APPS in settings.py
  3. Verify the view has filter_backends and filterset_fields configured
  4. Restart the backend: docker compose restart backend

Test the API directly:

curl "http://localhost:8000/api/properties/cached/?property_type=apartment" | jq '.results[0].property_type'
Enter fullscreen mode Exit fullscreen mode

All results should be "apartment". If not, the backend filtering is broken.

Issue 5: Images Don't Load (Broken Icon)

Symptom: Property cards show broken image icons.

Cause: We're using a blue gradient placeholder. There are no real images yet.

Fix: This is expected. We'll add real images in Part 6. For now, the placeholder is correct.

If you want to test with real images temporarily, replace the image div in PropertyCard.tsx:

<div className="h-48 bg-gray-300 flex items-center justify-center">
  <img
    src="https://via.placeholder.com/400x300"
    alt={property.title}
    className="w-full h-full object-cover"
  />
</div>
Enter fullscreen mode Exit fullscreen mode

Troubleshooting 2

🛠 Troubleshooting - 2

1. Next.js 15 Synchronous Dynamic APIs

  • The Issue: The app crashed with an error stating searchParams is a Promise and must be unwrapped.
  • The Cause: In Next.js 15, searchParams and params props in Server Components are now asynchronous. Accessing them directly (e.g., searchParams.type) throws a TypeError.
  • The Solution: Use await to unwrap the props at the start of the component function.
export default async function Page({ searchParams }: { searchParams: Promise<any> }) {
  const filters = await searchParams;
  // Now use filters.type
}

Enter fullscreen mode Exit fullscreen mode

2. Docker Networking: ECONNREFUSED

  • The Issue: The Frontend container could not connect to the Backend API, even though the backend appeared to be running.
  • The Cause: Containers don't share localhost. On the server side (SSR), the frontend must use the Docker service name (e.g., http://backend:8000) instead of localhost.
  • The Solution: Create a dynamic base URL utility that checks if the code is running in the browser or on the server.
const API_URL = typeof window === "undefined" ? "http://backend:8000" : "http://localhost:8000";

Enter fullscreen mode Exit fullscreen mode

3. Missing Python Dependencies in Docker

  • The Issue: Backend logs showed ModuleNotFoundError: No module named 'django_filters'.
  • The Cause: The Django app required a package that wasn't included in the initial Docker image build or requirements.txt.
  • The Solution: Add the package to requirements.txt and rebuild the container using docker-compose up --build.

4. Permission Denied (EACCES) on npm install

  • The Issue: Running npm install locally failed with permission errors in the node_modules folder.
  • The Cause: Because Docker was run with root privileges, it created the node_modules folder on the host machine as root, preventing the local user from modifying it.
  • The Solution: Reclaim ownership of the project files using chown.
sudo chown -R $USER:$USER .

Enter fullscreen mode Exit fullscreen mode

5. Module Not Found inside Docker Volumes

  • The Issue: swr was installed locally, but the frontend container still claimed it couldn't find the module.
  • The Cause: An Anonymous Volume in docker-compose.yml (- /app/node_modules) was masking the local folder. The container was looking at an isolated, outdated version of the modules.
  • The Solution: Use docker-compose exec to install the package directly inside the running container volume, or perform a clean rebuild with docker-compose down -v.

6. Missing Database Relations

  • The Issue: The frontend finally loaded, but the backend threw an error: relation "housing_property" does not exist.
  • The Cause: The Postgres database was running, but the Django migrations hadn't been applied to create the table schema.
  • The Solution: Run the migrations through the Docker container.
docker-compose exec backend python manage.py migrate

Enter fullscreen mode Exit fullscreen mode

What We Built — And What's Next

We started Part 5 with a Django API and a blank Next.js page. We ended with a complete housing portal:

The UI is functional. Users can browse 5,000 properties, filter by type/city/price, and paginate through results. The cards display all the essential information: title, price, location, agent.

The caching is triple-layered. Redis caches at the backend (4ms). SWR caches in the browser (0ms on repeat visits). The system is both fast and fresh.

The performance is measurable. First load: ~50ms. Repeat load: instant. We can prove it in DevTools.

The filters work. URL state drives the filters. The URL is shareable — send someone http://localhost:3000/?type=house&city=Seattle and they see exactly what you see.

But there's still something missing. The blue gradient placeholders. No real images. No property detail pages. No agent contact info. No map view. Those are polish, not core functionality — but they matter. Part 6 tackles images. Part 7 tackles deployment.

Here's what we'll do in Part 6 - The Image Layer:

  1. Image hosting — Integrate Cloudinary or a similar CDN service
  2. Upload flow — Let admins upload images in the Django admin
  3. Thumbnail generation — Automatically create thumbnails on upload
  4. Fill the null fields — Populate cdn_url and thumbnail_url that have been sitting empty since Part 2
  5. Lazy loading — Load images only when they scroll into view
  6. Next.js Image optimization — Automatic WebP conversion, responsive srcsets
  7. Measure the difference — Compare serving images from Django vs a CDN

The image layer is the final performance bottleneck. A high-res property photo can be 2-5MB. Twenty of them on a page is 40-100MB. Over a slow connection, that's 30 seconds of loading. The CDN and Next.js Image component solve this. We'll measure the before and after.


Checkpoint: Push to GitHub

git add .
git commit -m "feat: add Next.js frontend with SWR caching, filters, and pagination"
git checkout -b part-5-frontend
git push origin part-5-frontend
Enter fullscreen mode Exit fullscreen mode

The repo now has five branches:

  • part-1-setup — infrastructure
  • part-2-data — database and seed
  • part-3-problem — API and Redis
  • part-4-optimization — query optimization and signals
  • part-5-frontend — Next.js UI with SWR

Diff any two branches to see what changed:

git diff part-4-optimization..part-5-frontend
Enter fullscreen mode Exit fullscreen mode

Next: Part 6 — The Image Layer: CDN Integration and Lazy Loading. Stay tuned.

Top comments (0)