Build a Real Estate Listings Platform

Build a React property listings app with filters and a detail view.

Introduction

30 Second Summary

Everyone has spent time scrolling through property listings online. You filter by price, set a minimum number of bedrooms, and browse photos until something catches your eye.

In this project, you will build a property listings platform called HomeHunt using React. You will create a responsive card grid of international properties, add interactive filters that update results instantly, and build a detail view for each listing.

What You'll Build

Imagine pulling up your HomeHunt app, scrolling through a grid of international property cards, narrowing the results down with price and bedroom filters, and clicking into a listing to see its full details in a polished modal.

By the end of this project, you'll have:

  • A responsive card grid of property listings with photos, formatted prices, locations, and key stats like bedrooms, bathrooms, and area.
  • Interactive filters for price range, minimum bedrooms, and property type that combine and update results in real time.
  • A modal detail view that opens when you click any property card, showing a larger photo, complete description, and all property specifications.
  • Secret Mission: Connect HomeHunt to a live real estate API to replace mock data with real international property listings.

Are there any prerequisites?

This project assumes basic knowledge of HTML, CSS, JavaScript, and React fundamentals. You should also have VS Code and Node.js installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Scaffold the React Project

Before building any features for HomeHunt, you need a working development environment. A dev environment gives you a place to write code and instantly see changes in the browser.

You will use Vite, a modern build tool, to scaffold your React project. Vite starts a local development server that refreshes the browser every time you save a file.

In this step, get ready to:
  • Scaffold a Vite + React project called homehunt.
  • Replace the starter files with a clean base for your app.
  • Verify the app runs and displays the HomeHunt heading on a dark page.
Create the Vite project
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open a terminal inside VS Code by pressing Cmd+` (macOS) or Ctrl+` (Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the project by running this command:
npm create vite@latest homehunt -- --template react

What will Vite ask you?

The scaffold prompts you with two questions before it finishes.

  • It asks which linter to use. Press Enter to accept the default option.
  • It asks whether to install dependencies and start the dev server now. Select No for this one. You will install and start manually in the next commands.
  • Move into the project folder and install dependencies by running these commands:
cd homehunt
npm install
  • Start the development server by running:
npm run dev

What does this command do?

This starts Vite's development server. It watches your files and refreshes the browser automatically when you save changes.

The terminal displays a local URL (starting with http://localhost). You will use that URL to view your app. The server takes over your terminal while it runs, so leave this terminal open.

Before you open the URL, what do you expect to see in the browser?

  • Open the URL shown in your terminal in a web browser.

You should see the default Vite + React starter page with a Vite logo, a React logo, and a counter button.

Don't see the starter page?

  • Make sure your terminal is still running the npm run dev command. If you closed it, run npm run dev again from inside the homehunt folder.
  • Double-check that you copied the exact URL from the terminal output, including the port number.

Help me troubleshoot my Vite dev server.

Replace the starter files with a clean base

The default Vite scaffold comes with demo content you do not need. You will replace it with a minimal starting point for HomeHunt.

  • In the VS Code sidebar, expand the src folder and click on App.jsx to open it.
  • Select all the existing code (Cmd+A on macOS or Ctrl+A on Windows) and replace it with this:
// Import the app stylesheet (empty for now)
import './App.css';

// Main component renders the HomeHunt heading
function App() {
  return (
    <div>
      <h1>HomeHunt</h1>
    </div>
  );
}

export default App;

What does this code do?

  • import './App.css' loads the app's stylesheet so styles apply when you add them later.
  • The App function is a React component that returns JSX. Right now it renders a single h1 heading.
  • export default App makes this component available to main.jsx, which loads it into the page.
  • Save App.jsx.
  • Check your browser. You should see just the text "HomeHunt" as plain white text. The Vite logos and counter button are gone.

Still seeing the Vite starter page?

  • Make sure you saved src/App.jsx. Unsaved files show a dot next to their name in the VS Code tab bar.
  • Check that you replaced all the code in the file, not just part of it.

Help me fix my App.jsx file.

Now you will set up the stylesheets. All styles for this project live in index.css, so you will clear App.css and build your base styles in index.css.

  • Open src/App.css in the sidebar.
  • Select all the existing code and replace it with this single comment:
/* Kept empty - all styles in index.css */
  • Now open src/index.css in the sidebar.
  • Select all the existing code and replace it with these base styles:
/* Reset default browser spacing */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* Base page styles - dark theme with system font */
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background-color: #0f1117;
  color: #e1e1e6;
  line-height: 1.6;
}

What do these styles do?

  • The * selector removes all default margins and padding from every element. box-sizing: border-box makes width and height calculations predictable.
  • The body rule sets a dark background (#0f1117), light text (#e1e1e6), and a clean system font stack.

Before you check the browser, what do you think the page will look like now?

  • Save both src/App.css and src/index.css.
  • Check your browser. You should see "HomeHunt" as light gray text centered on a dark, almost-black background.

Page still has a white background?

  • Make sure you saved src/index.css. The browser hot-reloads only after you save.
  • Check that the background-color value is exactly #0f1117 with no typos.

Help me fix my CSS styles.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Compare your files against these. Make sure each file matches exactly.

import './App.css';

function App() {
  return (
    <div>
      <h1>HomeHunt</h1>
    </div>
  );
}

export default App;
/* Kept empty - all styles in index.css */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background-color: #0f1117;
  color: #e1e1e6;
  line-height: 1.6;
}

Your development environment is ready and HomeHunt is live in the browser. Next up, you will fill that page with property listings in a card grid.

Display Property Listings in a Card Grid

In the last step, you got HomeHunt running with a clean dark page showing nothing but a heading. A listings platform needs to display properties visually so users can browse at a glance.

In this step, you'll create a set of property listings, build a reusable card component to render each one, and arrange them in a responsive grid that adapts from 1 column on mobile to 3 columns on desktop.

In this step, get ready to:
  • Create a data file with property listings that simulate an API response.
  • Build a PropertyCard component to display each property.
  • Render a responsive card grid in App.jsx using CSS Grid.
Create the property data and card component

Before you can render property cards, you need data to display. A local data file lets you build and style the UI without worrying about API calls yet.

  • In the Explorer sidebar in VS Code, right-click on the src folder.
  • Click New Folder and name it data.
  • Right-click on the new data folder, click New File, and name it listings.js.
  • Paste the following code into listings.js to create the first two property listings:
const listings = [
  {
    id: 1,
    title: "Modern Apartment in Lisbon",
    price: 245000,
    currency: "USD",
    location: "Alfama, Lisbon",
    country: "Portugal",
    bedrooms: 2,
    bathrooms: 1,
    area_sqm: 85,
    type: "apartment",
    image: "https://picsum.photos/seed/apt1/400/300",
    description: "A beautifully renovated apartment in the heart of Alfama with stunning river views. Features modern kitchen, hardwood floors, and a private balcony overlooking the Tagus River. Walking distance to restaurants, cafes, and public transport."
  },
  {
    id: 2,
    title: "Seaside Villa in Barcelona",
    price: 520000,
    currency: "USD",
    location: "Sitges, Barcelona",
    country: "Spain",
    bedrooms: 4,
    bathrooms: 3,
    area_sqm: 210,
    type: "villa",
    image: "https://picsum.photos/seed/villa1/400/300",
    description: "Stunning Mediterranean villa just steps from the beach. Features a private pool, landscaped garden, and panoramic sea views. The open-plan living area flows onto a sun-drenched terrace perfect for entertaining."
  }
];

export default listings;

What does this data represent?

Each listing object has fields that describe a property: id for uniquely identifying it, price and location for the key details, and type to categorize it as an apartment, house, or villa.

The image field uses picsum.photos, a free placeholder image service. The seed value (like apt1) gives each listing a unique but consistent photo every time the page loads.

You'll add the remaining 6 listings later in this step. First, let's build the component that turns this data into visual cards.

  • Right-click on the src folder in the Explorer sidebar.
  • Click New Folder and name it components.
  • Right-click on components, click New File, and name it PropertyCard.jsx.
  • Paste the following code into PropertyCard.jsx:
function PropertyCard({ listing, onClick }) {
  // Format prices into a short readable string
  const formatPrice = (price) => {
    if (price >= 1000000) {
      return `$${(price / 1000000).toFixed(1)}M`;
    }
    return `$${(price / 1000).toFixed(0)}k`;
  };

  return (
    <div className="property-card" onClick={() => onClick(listing)}>
      <div className="card-image">
        <img src={listing.image} alt={listing.title} />
        <span className="card-type">{listing.type}</span>
      </div>
      <div className="card-content">
        <h3 className="card-price">{formatPrice(listing.price)}</h3>
        <p className="card-title">{listing.title}</p>
        <p className="card-location">{listing.location}, {listing.country}</p>
        <div className="card-stats">
          <span>{listing.bedrooms} bed</span>
          <span>{listing.bathrooms} bath</span>
          <span>{listing.area_sqm} m²</span>
        </div>
      </div>
    </div>
  );
}

export default PropertyCard;

What does this component do?

PropertyCard receives two props: a listing object with all the property data, and an onClick function that fires when a user clicks the card.

The formatPrice helper converts raw numbers like 245000 into readable strings like $245k. Prices at or above one million display as $1.0M.

  • Save both listings.js and PropertyCard.jsx.
  • Confirm both files appear in the Explorer sidebar: src/data/listings.js and src/components/PropertyCard.jsx.

Don't see the new folders?

Make sure you created the data and components folders inside src, not at the project root. Your folder tree should read src/data/listings.js and src/components/PropertyCard.jsx.

help me debug my folder structure in VS Code

Render the card grid

You have data and a card component. Now you need to connect them inside App.jsx so the browser actually renders property cards.

  • Open src/App.jsx and replace its entire contents with this code:
import listings from "./data/listings";
import PropertyCard from "./components/PropertyCard";

function App() {
  return (
    <div className="app">
      <header className="app-header">
        <h1>HomeHunt</h1>
        <p>Find your dream property worldwide</p>
      </header>
      <div className="listings-grid">
        {listings.map((listing) => (
          <PropertyCard
            key={listing.id}
            listing={listing}
            onClick={() => {}}
          />
        ))}
      </div>
    </div>
  );
}

export default App;

What does this code do?

The listings.map() call loops through every listing in your data array and renders a PropertyCard for each one. React requires a unique key prop on each item in a list so it can efficiently update the DOM when data changes.

The onClick prop is an empty function for now. You'll wire it up in a later step to show property details when a card is clicked.

  • Save App.jsx and check your browser.

You should see two property images and some unstyled text. The data is rendering, but it looks rough without CSS.

Time to add styles. The layout CSS handles the page structure and the responsive grid. The card CSS styles each property card.

  • Open src/index.css and add these layout styles below the existing body rule:
.app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.app-header {
  text-align: center;
  padding: 40px 0 30px;
}

.app-header h1 {
  font-size: 2.5rem;
  color: #7c5cff;
  margin-bottom: 8px;
}

.app-header p {
  color: #8b8b9a;
  font-size: 1.1rem;
}

.listings-grid {
  display: grid;
  grid-template-columns: repeat(1, 1fr);
  gap: 24px;
}

@media (min-width: 640px) {
  .listings-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}
  • Continue adding these card structure and image styles directly below the code you just pasted:
@media (min-width: 1024px) {
  .listings-grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

.property-card {
  background: #1a1b23;
  border-radius: 12px;
  overflow: hidden;
  cursor: pointer;
  transition: transform 0.2s, box-shadow 0.2s;
}

.property-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 8px 30px rgba(124, 92, 255, 0.15);
}

.card-image {
  position: relative;
  height: 200px;
  overflow: hidden;
}

.card-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

What is CSS Grid doing here?

The .listings-grid starts as a single-column grid. The @media rules switch to 2 columns at 640px and 3 columns at 1024px. This means the grid automatically adapts from phones to desktops without any JavaScript.

  • Save index.css and check your browser.

You should see two property cards in a grid layout, each showing a photo with a hover lift effect. The cards still need their text content styled.

  • Add these card content styles below the code you just pasted in index.css:
.card-type {
  position: absolute;
  top: 12px;
  left: 12px;
  background: rgba(124, 92, 255, 0.9);
  color: white;
  padding: 4px 10px;
  border-radius: 6px;
  font-size: 0.75rem;
  text-transform: capitalize;
}

.card-content {
  padding: 16px;
}

.card-price {
  font-size: 1.4rem;
  font-weight: 700;
  color: #7c5cff;
  margin-bottom: 4px;
}

.card-title {
  font-size: 0.95rem;
  color: #e1e1e6;
  margin-bottom: 4px;
}
  • Add these final card styles directly below:
.card-location {
  font-size: 0.85rem;
  color: #8b8b9a;
  margin-bottom: 12px;
}

.card-stats {
  display: flex;
  gap: 16px;
  font-size: 0.85rem;
  color: #8b8b9a;
}
  • Save index.css and check your browser.

Each card now shows a purple type badge on the image, a price in purple, the property title, location, and stats for beds, baths, and area. Hover over a card to see it lift up with a purple shadow.

Cards look broken or unstyled?

Check that you pasted each CSS block in order, directly below the previous one, inside index.css. A missing closing brace } from one block will break every rule below it.

Make sure the class names in your CSS match the JSX exactly. For example, className="property-card" in PropertyCard.jsx must match .property-card in your CSS.

help me debug my property card styles

✔️ Awesome, I've got everything!

Great. Make sure you've saved both App.jsx and index.css.

ⓧ I'd like to double check the full code

Here is the complete PropertyCard.jsx:

function PropertyCard({ listing, onClick }) {
  const formatPrice = (price) => {
    if (price >= 1000000) {
      return `$${(price / 1000000).toFixed(1)}M`;
    }
    return `$${(price / 1000).toFixed(0)}k`;
  };

  return (
    <div className="property-card" onClick={() => onClick(listing)}>
      <div className="card-image">
        <img src={listing.image} alt={listing.title} />
        <span className="card-type">{listing.type}</span>
      </div>
      <div className="card-content">
        <h3 className="card-price">{formatPrice(listing.price)}</h3>
        <p className="card-title">{listing.title}</p>
        <p className="card-location">{listing.location}, {listing.country}</p>
        <div className="card-stats">
          <span>{listing.bedrooms} bed</span>
          <span>{listing.bathrooms} bath</span>
          <span>{listing.area_sqm} m²</span>
        </div>
      </div>
    </div>
  );
}

export default PropertyCard;

Here is the complete App.jsx:

import listings from "./data/listings";
import PropertyCard from "./components/PropertyCard";

function App() {
  return (
    <div className="app">
      <header className="app-header">
        <h1>HomeHunt</h1>
        <p>Find your dream property worldwide</p>
      </header>
      <div className="listings-grid">
        {listings.map((listing) => (
          <PropertyCard
            key={listing.id}
            listing={listing}
            onClick={() => {}}
          />
        ))}
      </div>
    </div>
  );
}

export default App;

For index.css, your file should contain the original reset and body styles at the top, followed by all four CSS blocks you added (layout styles, card structure styles, card type and price styles, card location and stats styles). Scroll up and confirm each block is present in order.

Add the remaining property listings

Your grid works with 2 listings. A real platform needs variety. Let's fill it out with 6 more properties across different countries, price ranges, and types.

  • In src/data/listings.js, find the closing ]; at the bottom of the array.
  • Add a comma after the closing brace } of listing 2 (the Barcelona villa), then paste these two listings above the ];:
  {
    id: 3,
    title: "Cozy Studio in Paris",
    price: 189000,
    currency: "USD",
    location: "Le Marais, Paris",
    country: "France",
    bedrooms: 1,
    bathrooms: 1,
    area_sqm: 35,
    type: "apartment",
    image: "https://picsum.photos/seed/studio1/400/300",
    description: "Charming studio in the trendy Le Marais district. Recently updated with modern amenities while preserving original Parisian character including exposed beams and herringbone parquet floors."
  },
  {
    id: 4,
    title: "Family House in Dublin",
    price: 385000,
    currency: "USD",
    location: "Ranelagh, Dublin",
    country: "Ireland",
    bedrooms: 3,
    bathrooms: 2,
    area_sqm: 140,
    type: "house",
    image: "https://picsum.photos/seed/house1/400/300",
    description: "A spacious Victorian-era house in one of Dublin's most sought-after neighborhoods. Features a large back garden, updated kitchen, and period features throughout. Excellent schools and transport links nearby."
  },
  • Add these two listings directly below the Dublin house, above the ];:
  {
    id: 5,
    title: "Luxury Penthouse in Milan",
    price: 750000,
    currency: "USD",
    location: "Brera, Milan",
    country: "Italy",
    bedrooms: 3,
    bathrooms: 2,
    area_sqm: 160,
    type: "apartment",
    image: "https://picsum.photos/seed/pent1/400/300",
    description: "Exclusive penthouse in Milan's art district with a wraparound terrace offering 360-degree city views. High-end finishes, smart home technology, and a private elevator entrance. Steps from galleries and designer boutiques."
  },
  {
    id: 6,
    title: "Countryside Cottage in Tuscany",
    price: 295000,
    currency: "USD",
    location: "Chianti, Tuscany",
    country: "Italy",
    bedrooms: 2,
    bathrooms: 1,
    area_sqm: 95,
    type: "house",
    image: "https://picsum.photos/seed/cottage1/400/300",
    description: "A restored stone cottage surrounded by vineyards and olive groves. Features original terracotta floors, a wood-burning fireplace, and a private garden with views of the rolling Tuscan hills."
  },
  • Save listings.js and check your browser.

You should now see 6 property cards in the grid, including the Paris studio, Dublin house, Milan penthouse, and Tuscany cottage.

  • Add the final two listings below the Tuscany cottage, above the ];:
  {
    id: 7,
    title: "Beachfront Villa in Algarve",
    price: 480000,
    currency: "USD",
    location: "Lagos, Algarve",
    country: "Portugal",
    bedrooms: 4,
    bathrooms: 3,
    area_sqm: 200,
    type: "villa",
    image: "https://picsum.photos/seed/beach1/400/300",
    description: "Direct beach access from this contemporary villa featuring infinity pool, outdoor kitchen, and floor-to-ceiling windows. The master suite has ocean views and a private balcony. Perfect for vacation rental income."
  },
  {
    id: 8,
    title: "City Apartment in Berlin",
    price: 175000,
    currency: "USD",
    location: "Kreuzberg, Berlin",
    country: "Germany",
    bedrooms: 1,
    bathrooms: 1,
    area_sqm: 55,
    type: "apartment",
    image: "https://picsum.photos/seed/berlin1/400/300",
    description: "A stylish apartment in Berlin's vibrant Kreuzberg neighborhood. High ceilings, open layout, and large windows flood the space with natural light. Surrounded by cafes, parks, and nightlife."
  }

What should I see?

After saving, make sure listing 8 (Berlin) has NO trailing comma after its closing }, and that the ]; and export default listings; lines sit directly below. Your array should contain exactly 8 objects separated by commas.

Before you save, how many property cards do you expect to see in the grid?

  • Save listings.js and check your browser.

You should see 8 property cards arranged in a responsive grid. Each card shows a photo, a purple type badge, price, title, location, and stats for beds, baths, and area. Try resizing the browser window to see the grid adapt from 1 to 2 to 3 columns.

Seeing fewer than 8 cards or a blank page?

Check the terminal running your dev server. A syntax error in listings.js (a missing comma between objects, an extra comma after the last object, or a missing closing bracket) will prevent the page from loading.

Make sure every listing object except the last one (Berlin) ends with a comma. The Berlin object should end with just } followed by ]; on the next line.

help me fix my listings data file

✔️ Awesome, I've got everything!

Great. Confirm you've saved listings.js and see all 8 cards in the browser.

ⓧ I'd like to double check the full code

For listings.js, your file should start with const listings = [ and contain all 8 listing objects in order (Lisbon, Barcelona, Paris, Dublin, Milan, Tuscany, Algarve, Berlin), followed by ]; and export default listings;. Scroll up through the code blocks in this step and confirm each pair of listings is present in order.

For index.css, your file should contain the original reset and body styles at the top, followed by all four CSS blocks you added. Scroll up to verify each CSS block is present in order.

Your property grid is looking sharp. But right now every listing shows at once with no way to narrow things down. Next up, you'll build a filter bar so users can search by price, bedrooms, and property type.

Add Search and Filters

Your card grid looks great, but right now every single listing shows at once.

On a real listings platform, users need to narrow results fast. In this step, you will build a filter bar that lets users search by price range, bedrooms, and property type. The grid will update instantly as filters change.

In this step, get ready to:
  • Build a reusable FilterBar component with three dropdown controls.
  • Wire up filter state and a filtering function in App.jsx so the grid updates in real time.
  • Handle the empty state when no listings match the active filters.
Build the FilterBar component

Filters are their own responsibility, separate from the card grid. Keeping them in a dedicated component makes the code easier to manage and reuse.

  • Create a new file called FilterBar.jsx inside the src/components/ folder.
  • Start the component with a price range dropdown by pasting the following code:
function FilterBar({ filters, onFilterChange, resultCount, totalCount }) {
  return (
    <div className="filter-bar">
      {/* Price range dropdown */}
      <div className="filter-group">
        <label htmlFor="price-filter">Price Range</label>
        <select
          id="price-filter"
          value={filters.priceRange}
          onChange={(e) => onFilterChange("priceRange", e.target.value)}
        >
          <option value="any">Any Price</option>
          <option value="0-100000">Under $100k</option>
          <option value="100000-300000">$100k - $300k</option>
          <option value="300000-500000">$300k - $500k</option>
          <option value="500000+">$500k+</option>
        </select>
      </div>

      {/* Result count display */}
      <p className="result-count">
        Showing {resultCount} of {totalCount} properties
      </p>
    </div>
  );
}

export default FilterBar;

What does this code do?

FilterBar receives four props from its parent: the current filters object, an onFilterChange callback, plus two numbers for the result count.

Each <select> fires onFilterChange whenever the user picks a new option, sending the filter name and the selected value up to the parent component.

This gives you a working component with one filter. Now add the bedrooms and property type dropdowns.

  • In FilterBar.jsx, find the closing </div> of the price filter group (the one right above the {/* Result count display */} comment).
  • Add the following two filter groups between that closing </div> and the {/* Result count display */} comment:
        {/* Bedrooms dropdown */}
        <div className="filter-group">
          <label htmlFor="beds-filter">Bedrooms</label>
          <select
            id="beds-filter"
            value={filters.minBedrooms}
            onChange={(e) => onFilterChange("minBedrooms", e.target.value)}
          >
            <option value="0">Any</option>
            <option value="1">1+</option>
            <option value="2">2+</option>
            <option value="3">3+</option>
            <option value="4">4+</option>
          </select>
        </div>

        {/* Property type dropdown */}
        <div className="filter-group">
          <label htmlFor="type-filter">Type</label>
          <select
            id="type-filter"
            value={filters.propertyType}
            onChange={(e) => onFilterChange("propertyType", e.target.value)}
          >
            <option value="all">All Types</option>
            <option value="apartment">Apartment</option>
            <option value="house">House</option>
            <option value="villa">Villa</option>
          </select>
        </div>

What do these dropdowns do?

The bedrooms dropdown lets users filter by minimum bedroom count. Its values are stored as strings ("0" through "4") and parsed into numbers when the parent filters the data.

The type dropdown filters by property type. Its value matches the type field in each listing object (apartment, house, or villa).

  • Save FilterBar.jsx.
  • In the VS Code Explorer sidebar, confirm FilterBar.jsx now appears inside src/components/ alongside PropertyCard.jsx.

Don't see FilterBar.jsx?

Make sure you created the file inside src/components/, not in the project root or inside src/ directly. The file extension must be .jsx, not .js.

Help me find my FilterBar.jsx file

✔️ Awesome, I've got everything!

Great. Double check you saved the file before moving on.

ⓧ I'd like to double check the full code

Your FilterBar.jsx is built from two chunks. Scroll up and confirm these are present in order:

  • The function signature, price range filter-group, result count paragraph, closing braces, and export default FilterBar.
  • The bedrooms and property type filter-group divs, inserted between the price group and the result count.
Connect filters to the listings grid

The FilterBar component is ready, but it is not rendering on the page yet. You need to import it into App.jsx, create state to track filter selections, and write a function that narrows the listings array to only matching properties.

  • Open App.jsx and replace the import lines at the top of the file and the opening of the App function with the following code:
import { useState } from "react";
import listings from "./data/listings";
import PropertyCard from "./components/PropertyCard";
import FilterBar from "./components/FilterBar";

function App() {
  // Track the current filter selections
  const [filters, setFilters] = useState({
    priceRange: "any",
    minBedrooms: "0",
    propertyType: "all",
  });
  // Track which property card was clicked (used in a later step)
  const [selectedProperty, setSelectedProperty] = useState(null);

  // Update a single filter field by name
  const handleFilterChange = (filterName, value) => {
    setFilters((prev) => ({ ...prev, [filterName]: value }));
  };

  // Reset all filters back to defaults
  const resetFilters = () => {
    setFilters({
      priceRange: "any",
      minBedrooms: "0",
      propertyType: "all",
    });
  };

What does this code do?

The useState hook creates a filters object that holds the current value of each dropdown. Whenever the user picks a new option, handleFilterChange updates just that one field while keeping the others intact.

The resetFilters function snaps every filter back to its default value. You will wire this up to a button later in this step.

Next, add the filtering logic that decides which listings to display.

  • Still in App.jsx, add the following code after the resetFilters function, before the return statement:
  // Filter listings based on current filter values
  const filteredListings = listings.filter((listing) => {
    // Check price range
    if (filters.priceRange !== "any") {
      if (filters.priceRange.includes("+")) {
        const min = parseInt(filters.priceRange);
        if (listing.price < min) return false;
      } else {
        const [min, max] = filters.priceRange.split("-").map(Number);
        if (listing.price < min || listing.price > max) return false;
      }
    }

    // Check minimum bedrooms
    if (filters.minBedrooms !== "0") {
      if (listing.bedrooms < parseInt(filters.minBedrooms)) return false;
    }

    // Check property type
    if (filters.propertyType !== "all") {
      if (listing.type !== filters.propertyType) return false;
    }

    return true;
  });

How does the filtering work?

The .filter() method runs each listing through every active filter. If a listing fails any check, return false removes it from the results. Listings that pass all checks reach return true and stay in the array.

Price ranges are stored as strings like "100000-300000" or "500000+". The code splits or parses them into numbers before comparing against each listing's price.

  • Save App.jsx.
  • Check your browser. It should still show the HomeHunt heading and all 8 property cards with no errors.

Seeing a blank page or errors?

Make sure the four import lines at the top of App.jsx are exactly right. A missing { useState } import or a typo in the FilterBar path will break the page.

Help me fix my App.jsx imports

The logic is in place but the filter bar is still invisible. Before you update the return statement, add the CSS that will style it.

  • Switch to index.css and add the following styles at the bottom of the file, after the .app-header p rule and before the .listings-grid rule. Copy both code blocks in order:
/* Filter bar container */
.filter-bar {
  display: flex;
  gap: 16px;
  align-items: flex-end;
  flex-wrap: wrap;
  padding: 20px;
  background: #1a1b23;
  border-radius: 12px;
  margin-bottom: 30px;
}

/* Individual filter column */
.filter-group {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/* Uppercase label above each dropdown */
.filter-group label {
  font-size: 0.8rem;
  color: #8b8b9a;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
/* Dropdown styling */
.filter-group select {
  padding: 10px 14px;
  background: #252630;
  color: #e1e1e6;
  border: 1px solid #333;
  border-radius: 8px;
  font-size: 0.95rem;
  cursor: pointer;
}

/* Purple border on focused dropdown */
.filter-group select:focus {
  outline: none;
  border-color: #7c5cff;
}

/* Result count pushed to the right */
.result-count {
  margin-left: auto;
  color: #8b8b9a;
  font-size: 0.9rem;
}
  • Save index.css.

Now update the App component's return statement to render the FilterBar and use the filtered array.

  • In App.jsx, replace the entire return (...) block, the closing }, and the export default App line with the following:
  return (
    <div className="app">
      <header className="app-header">
        <h1>HomeHunt</h1>
        <p>Find your dream property worldwide</p>
      </header>

      {/* Render the filter bar with current state */}
      <FilterBar
        filters={filters}
        onFilterChange={handleFilterChange}
        resultCount={filteredListings.length}
        totalCount={listings.length}
      />

      {/* Display filtered listings in a grid */}
      <div className="listings-grid">
        {filteredListings.map((listing) => (
          <PropertyCard
            key={listing.id}
            listing={listing}
            onClick={setSelectedProperty}
          />
        ))}
      </div>
    </div>
  );
}

export default App;

What changed in the return?

The <FilterBar> component sits between the header and the grid. It receives the current filter state and a callback to update it.

The grid now maps over filteredListings instead of the full listings array. Every time a dropdown changes, React re-runs the filter and re-renders only the matching cards.

Before you save, what do you think will appear above the card grid?

  • Save App.jsx and check your browser.

A dark filter bar with three dropdowns appears above the grid. The right side reads "Showing 8 of 8 properties."

  • Change the Price Range dropdown to Under $100k.

The grid should shrink to zero cards and the count should update to "Showing 0 of 8 properties" because none of the mock listings are under $100k.

  • Set Price Range back to Any Price, then change Bedrooms to 3+.

The grid now shows only properties with 3 or more bedrooms. The count updates to match.

Filter bar not appearing?

Check that your App.jsx imports FilterBar from "./components/FilterBar". A wrong path silently fails. Also confirm the .filter-bar CSS rules are saved in index.css.

Help me debug my filter bar

Handle the empty state

When filters are too narrow and no listings match, the grid goes blank. That leaves the user staring at an empty page with no guidance. A friendly empty state with a reset button solves this.

  • In App.jsx, find the listings-grid div in your return statement. It currently looks like this:
      <div className="listings-grid">
        {filteredListings.map((listing) => (
          <PropertyCard
            key={listing.id}
            listing={listing}
            onClick={setSelectedProperty}
          />
        ))}
      </div>
  • Replace that entire block with a conditional that checks filteredListings.length. If there are results, show the grid. Otherwise, show a "No properties found" message with a reset button:
      {filteredListings.length > 0 ? (
        <div className="listings-grid">
          {filteredListings.map((listing) => (
            <PropertyCard
              key={listing.id}
              listing={listing}
              onClick={setSelectedProperty}
            />
          ))}
        </div>
      ) : (
        <div className="no-results">
          <h2>No properties found</h2>
          <p>Try adjusting your filters to see more results.</p>
          <button onClick={resetFilters}>Reset Filters</button>
        </div>
      )}

What is the ternary doing?

The ? : is a conditional rendering pattern in React. When filteredListings.length > 0 is true, the grid renders as before. When it is false, a .no-results div appears instead.

The reset button calls resetFilters, which snaps all three dropdowns back to their defaults and brings every listing back into view.

The empty state also needs CSS so it looks clean. Add the no-results styles to index.css.

  • In index.css, add the following styles at the very bottom of the file, after the .card-stats rule:
/* Empty state container */
.no-results {
  text-align: center;
  padding: 60px 20px;
}

.no-results h2 {
  font-size: 1.5rem;
  margin-bottom: 8px;
}

.no-results p {
  color: #8b8b9a;
  margin-bottom: 20px;
}

/* Reset button */
.no-results button {
  padding: 10px 24px;
  background: #7c5cff;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  cursor: pointer;
}

.no-results button:hover {
  background: #6b4ee0;
}
  • Save both App.jsx and index.css.

What do you think will happen if you set the filters so narrow that no properties match?

  • In the browser, set Price Range to Under $100k and Bedrooms to 4+.

The card grid disappears. In its place you see a centered "No properties found" heading, a message to adjust your filters, and a purple Reset Filters button.

  • Click Reset Filters.

All three dropdowns snap back to their defaults and all 8 cards reappear.

Empty state not appearing?

Make sure you replaced the listings-grid div with the full ternary, not just added code below it. The old <div className="listings-grid"> should no longer appear at the top level of the return. Also check that the .no-results styles are saved in index.css.

Help me fix my empty state rendering

✔️ Awesome, I've got everything!

Great. Double check you saved both App.jsx and index.css before moving on.

ⓧ I'd like to double check the full code

Your App.jsx was built from three chunks. Scroll up and confirm these are present in order:

  • Four imports (useState, listings, PropertyCard, FilterBar), followed by filters state, selectedProperty state, handleFilterChange, and resetFilters.
  • The filteredListings computation with price range, bedrooms, and property type checks.
  • The return statement with the header, <FilterBar>, and a ternary that shows either the listings-grid or the no-results div.

Your index.css should include all of the original styles from Step 2 plus the filter-bar, filter-group, result-count, and no-results rules you added in this step.

Your filter bar is fully functional. Users can combine price, bedroom, and type filters to narrow results, and the empty state catches them when they go too far. Next up, you will build a detail view that opens when a user clicks a property card.

Build the Property Detail View

Your filters are working and the grid responds instantly. Users can narrow down properties by price, bedrooms, and type.

Right now, every card is a dead end. Clicking one does nothing visible. Users who spot a property they like have no way to learn more about it. In this step, you will build a detail modal that opens when a card is clicked, showing a larger photo, the full description, and all property specs.

In this step, get ready to:
  • Create a PropertyDetail component that renders a full-screen modal overlay.
  • Wire the modal into your app so clicking any card opens it.
  • Style the detail view and add three ways to close it: a close button, clicking the backdrop, and pressing Escape.
Create the PropertyDetail component

The detail view will be a modal overlay that appears on top of the listings grid. It needs to accept a listing object and a callback to close itself.

  • Create a new file at src/components/PropertyDetail.jsx and add the component's logic by pasting this code:
import { useEffect } from "react";

function PropertyDetail({ listing, onClose }) {
  // Close the modal when the user presses Escape
  useEffect(() => {
    const handleKeyDown = (e) => {
      if (e.key === "Escape") {
        onClose();
      }
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [onClose]);

  // Format price as full USD currency string
  const formatPrice = (price) => {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
      maximumFractionDigits: 0,
    }).format(price);
  };

What does this code do?

The useEffect hook attaches a keyboard listener when the component mounts. If the user presses Escape, it calls onClose to dismiss the modal. The cleanup function removes the listener when the component unmounts so it does not leak.

The formatPrice helper uses the browser's built-in Intl.NumberFormat to produce a full currency string like "$245,000" instead of the abbreviated card format.

  • Still in PropertyDetail.jsx, add the component's return statement and export directly below the formatPrice function:
  // Render the modal with property image, details, and specs
  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose}>X</button>
        <img className="detail-image" src={listing.image} alt={listing.title} />
        <div className="detail-body">
          <h2 className="detail-title">{listing.title}</h2>
          <p className="detail-price">{formatPrice(listing.price)}</p>
          <p className="detail-location">{listing.location}, {listing.country}</p>
          <div className="detail-specs">
            <div className="spec"><span className="spec-value">{listing.bedrooms}</span><span className="spec-label">Bedrooms</span></div>
            <div className="spec"><span className="spec-value">{listing.bathrooms}</span><span className="spec-label">Bathrooms</span></div>
            <div className="spec"><span className="spec-value">{listing.area_sqm}</span><span className="spec-label">m²</span></div>
            <div className="spec"><span className="spec-value">{listing.type}</span><span className="spec-label">Type</span></div>
          </div>
          <p className="detail-description">{listing.description}</p>
        </div>
      </div>
    </div>
  );
}

export default PropertyDetail;

What does this JSX render?

The outermost modal-backdrop div covers the entire screen. Clicking it calls onClose. The inner modal-content div uses e.stopPropagation() so clicks inside the modal do not bubble up and accidentally close it.

Inside the modal, the detail-specs grid displays bedrooms, bathrooms, area, and property type in a clean four-column layout.

  • Save PropertyDetail.jsx.
  • In the VS Code Explorer sidebar, expand src/components/. You should see PropertyDetail.jsx listed alongside PropertyCard.jsx and FilterBar.jsx.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your PropertyDetail.jsx contains both chunks in order:

  • Chunk 1: the import, useEffect hook, and formatPrice helper.
  • Chunk 2: the return statement with the full modal JSX and the export default at the bottom.
Connect the detail view to your app

Your app already tracks which property is selected with the selectedProperty state, and each PropertyCard calls setSelectedProperty on click. You just need to import the new component and render it when a property is selected.

  • In src/App.jsx, find this import line:
import FilterBar from "./components/FilterBar";
  • Add the PropertyDetail import directly below it, so the two lines look like this:
import FilterBar from "./components/FilterBar";
import PropertyDetail from "./components/PropertyDetail";
  • Scroll down to the bottom of the return statement in App.jsx. Find the closing )} of the ternary that ends the no-results block.
  • Add the conditional render for PropertyDetail between that )} and the closing </div> of the app:
      {selectedProperty && (
        <PropertyDetail
          listing={selectedProperty}
          onClose={() => setSelectedProperty(null)}
        />
      )}

What does this code do?

The && pattern is a conditional render. When selectedProperty is null, React skips the block entirely. When a card is clicked and a listing object is stored, React renders the PropertyDetail modal on top of everything else.

  • Save App.jsx and switch to your browser.
  • Click any property card. You should see the property's title, price, location, and description appear as raw unstyled text below the grid.

Nothing happens when you click a card?

  • Check that your import path is exactly ./components/PropertyDetail. A typo in the path prevents the component from loading.
  • Make sure you saved both PropertyDetail.jsx and App.jsx. Unsaved files do not trigger a hot reload.
  • Check the terminal running your dev server for any error messages. A syntax error in the JSX will prevent rendering.

Why isn't my PropertyDetail component showing when I click a card?

✔️ Awesome, I've got everything!

Great. Make sure App.jsx is saved before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your App.jsx has these changes:

  • Line 5: import PropertyDetail from "./components/PropertyDetail"; right after the FilterBar import.
  • Near the bottom of the return: the {selectedProperty && (...)} block between the ternary's closing )} and the app's closing </div>.
Style the detail modal

The detail content is rendering, but it is unstyled and sits at the bottom of the page. You need CSS to turn it into a centered modal overlay with proper layout.

  • Open src/index.css and add the modal overlay and container styles at the bottom of the file:
/* Modal overlay covering the full screen */
.modal-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.7);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
  padding: 20px;
}

/* Centered modal card */
.modal-content {
  background: #1a1b23;
  border-radius: 16px;
  max-width: 600px;
  width: 100%;
  max-height: 90vh;
  overflow-y: auto;
  position: relative;
}
  • Add the close button, image, and body container styles below the code you just pasted:
/* Close button positioned over the image */
.modal-close {
  position: absolute;
  top: 12px;
  right: 12px;
  background: rgba(0, 0, 0, 0.6);
  color: white;
  border: none;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  font-size: 1rem;
  cursor: pointer;
  z-index: 10;
}

.modal-close:hover {
  background: rgba(0, 0, 0, 0.8);
}

.detail-image {
  width: 100%;
  height: 300px;
  object-fit: cover;
}

.detail-body {
  padding: 24px;
}
  • Save index.css and click any property card in your browser.
  • You should see a dark overlay covering the screen with a modal card centered in the middle. The card has the property image, a circular X close button in the top-right corner, and the text content below.

Modal not appearing as an overlay?

  • Make sure .modal-backdrop has position: fixed. Without this, the backdrop sits in the normal document flow instead of covering the screen.
  • Check that the class names in your CSS match the JSX exactly. modal-backdrop in CSS must match className="modal-backdrop" in your component.

Why is my modal not appearing as a centered overlay?

  • Add the detail content typography styles below the code you just pasted:
/* Detail view typography and specs grid */
.detail-title {
  font-size: 1.5rem;
  margin-bottom: 8px;
}

.detail-price {
  font-size: 1.8rem;
  font-weight: 700;
  color: #7c5cff;
  margin-bottom: 4px;
}

.detail-location {
  color: #8b8b9a;
  margin-bottom: 20px;
}

.detail-specs {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;
  padding: 16px;
  background: #252630;
  border-radius: 10px;
  margin-bottom: 20px;
  text-align: center;
}
  • Add the spec item and description styles below:
/* Spec items and description text */
.spec-value {
  display: block;
  font-size: 1.2rem;
  font-weight: 600;
  color: #e1e1e6;
  text-transform: capitalize;
}

.spec-label {
  font-size: 0.75rem;
  color: #8b8b9a;
  text-transform: uppercase;
}

.detail-description {
  color: #b0b0ba;
  line-height: 1.7;
}
  • Save index.css and click a property card.
  • You should see the full styled modal with the large image at the top, the purple price, the location, a four-column specs grid showing bedrooms, bathrooms, area, and type, and the property description below.

Why a four-column grid for specs?

The detail-specs container uses CSS Grid with repeat(4, 1fr) to give each spec equal width. This makes the four data points scannable at a glance without cluttering the layout.

Before you test the close interactions, predict: what do you think will happen if you press the Escape key while the modal is open?

  • With the modal open, press the Escape key on your keyboard.
  • Click a card again to reopen the modal, then click the dark area outside the modal card.
  • Open the modal one more time and click the circular X button in the top-right corner of the image.

All three actions close the modal and return you to the listings grid. The Escape key works because of the useEffect hook you added earlier. The backdrop close works because the outer div calls onClose on click.

Clicking inside the modal closes it?

  • Check that modal-content has onClick={(e) => e.stopPropagation()}. Without this, clicks inside the modal bubble up to the backdrop and trigger onClose.

Why does clicking inside my modal close it instead of just clicking the backdrop?

✔️ Awesome, I've got everything!

Great. Make sure index.css is saved before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your index.css has all four CSS chunks added at the bottom, in order:

  • Chunk 1: .modal-backdrop and .modal-content.
  • Chunk 2: .modal-close, .modal-close:hover, .detail-image, and .detail-body.
  • Chunk 3: .detail-title, .detail-price, .detail-location, and .detail-specs.
  • Chunk 4: .spec-value, .spec-label, and .detail-description.

Your detail view is complete. Users can browse the grid, click any card to see the full listing, and close the modal however feels natural. Next up, the Secret Mission takes HomeHunt from mock data to live property listings.

Secret mission

Connect to Live Property Data

HomeHunt works great with mock data, but what if you could browse real property listings from around the world? In this secret mission, you'll connect to the Untera real estate API to pull live international property data into your app. This means handling asynchronous fetches, loading states, error recovery, and mapping a real API response to your existing components.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine, so there are no ongoing cloud costs. Decide whether to keep your project files, pause for later, or delete everything.

Resources you used:

  • The homehunt project folder on your Desktop.
  • A running Vite dev server in your terminal.
  • If you completed the Secret Mission: a free Untera API key (free tier, no charges).

Keep everything running

No action needed. Choose this if you want to keep building on HomeHunt or exploring the code.

  • Your project files live on your local machine with no ongoing costs.
  • Your Vite dev server keeps running in the terminal. You can stop it anytime with Ctrl+C.
  • If you signed up for Untera, your free API key remains active with no charges.

Pause - I'll come back to this later

Stop the running dev server to free up your terminal. Your project files stay on your Desktop for when you return.

  • In the terminal where the dev server is running, press Ctrl+C to stop it.
  • When you are ready to come back, open a terminal in the homehunt folder and start the dev server again by running:
npm run dev
  • Your Untera API key (if you created one) stays active with no charges. No action needed.

Delete - I don't want to use this again

Remove all project files from your machine and start fresh.

  • In the terminal where the dev server is running, press Ctrl+C to stop it.
  • Delete the project folder by running this command on macOS or Linux:
rm -rf ~/Desktop/homehunt
  • On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\homehunt
  • Confirm the folder is gone by running:
ls ~/Desktop
  • You should no longer see homehunt in the output.
  • If you completed the Secret Mission and want to revoke your Untera API key, contact Untera support through their website at untera.io. The free tier has no charges, so leaving the key active costs nothing.

Nice Work!

Nice Work!

You just built HomeHunt, a fully functional real estate listings platform powered by React. Users can browse property cards, narrow results with interactive filters, and click into any listing to see full details.

You've learned how to:

  • Build a responsive card grid layout using CSS Grid with media queries that adapt from one column on mobile to three columns on desktop.
  • Create reusable React components (PropertyCard, FilterBar, PropertyDetail) that communicate through props and callback functions.
  • Implement multi-criteria filtering that combines price range, bedrooms, and property type using JavaScript array methods, and build a modal detail view with keyboard support for closing with Escape.
  • Secret Mission: Connected HomeHunt to a live real estate API, handling async data fetching with loading and error states.

Ready to quiz yourself?