Real estate websites live or die by search speed and listing quality. Buyers expect to filter by price, location, bedrooms, and property type — then see results with photos, maps, and details instantly. Next.js gives you server-side rendering for SEO on every listing page, image optimization for property galleries, and API routes for dynamic search. This guide builds a complete property listing site with filter search, interactive maps, and detail pages.
Prerequisites
- →Node.js 20+
Required for Next.js 15 App Router, server components, and the latest React features.
- →Google Maps API Key
For displaying property locations on an interactive map. Enable the Maps JavaScript API and Geocoding API.
- →PostgreSQL Database
A PostgreSQL instance for storing property listings. Neon or Supabase offer generous free tiers.
- →TypeScript and React Experience
Comfort with TypeScript generics, React hooks, and URL search params for managing filter state.
Scaffold the Project and Define the Property Schema
Create a new Next.js app and set up Prisma with a property listing schema. The schema captures everything a real estate listing needs: address, price, bedrooms, bathrooms, square footage, property type, images, and geolocation coordinates. Prisma gives you type-safe queries and automatic TypeScript types.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Property {
id String @id @default(cuid())
title String
slug String @unique
description String
price Int
address String
city String
state String
zipCode String
latitude Float
longitude Float
bedrooms Int
bathrooms Float
sqft Int
propertyType PropertyType
status ListingStatus @default(ACTIVE)
images String[]
features String[]
yearBuilt Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([city, propertyType, status])
@@index([price])
@@index([bedrooms])
}
enum PropertyType {
HOUSE
CONDO
TOWNHOUSE
APARTMENT
LAND
}
enum ListingStatus {
ACTIVE
PENDING
SOLD
}Tip: Add composite indexes on the fields users filter by most (city + type + status) to keep search queries fast.
Tip: Store images as an array of URLs pointing to a CDN (like Cloudinary or Vercel Blob) rather than storing binary data in the database.
Build the Property Card Component
Create a reusable property card that displays the listing photo, price, address, key stats (bedrooms, bathrooms, sqft), and status badge. The card links to the full detail page. Use the Next.js Image component for optimized loading of property photos, which are typically the largest assets on the page.
// src/components/PropertyCard.tsx
import Image from "next/image";
import Link from "next/link";
import type { Property } from "@prisma/client";
const statusColors: Record<string, string> = {
ACTIVE: "bg-green-500/10 text-green-400 border-green-500/30",
PENDING: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
SOLD: "bg-red-500/10 text-red-400 border-red-500/30",
};
export default function PropertyCard({ property }: { property: Property }) {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
return (
<Link href={`/listings/${property.slug}`} className="group block">
<div className="overflow-hidden rounded-xl border border-gray-800 bg-gray-900/50 transition-all hover:border-blue-500/50">
<div className="relative aspect-[4/3] overflow-hidden">
<Image
src={property.images[0] ?? "/placeholder-property.jpg"}
alt={property.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
<span
className={`absolute left-3 top-3 rounded-full border px-2 py-1 text-xs font-medium ${statusColors[property.status]}`}
>
{property.status}
</span>
</div>
<div className="p-5">
<p className="text-2xl font-bold text-white">
{formatter.format(property.price)}
</p>
<p className="mt-1 text-sm text-gray-400">{property.address}</p>
<p className="text-sm text-gray-500">
{property.city}, {property.state} {property.zipCode}
</p>
<div className="mt-3 flex gap-4 text-sm text-gray-300">
<span>{property.bedrooms} bd</span>
<span>{property.bathrooms} ba</span>
<span>{property.sqft.toLocaleString()} sqft</span>
</div>
</div>
</div>
</Link>
);
}Tip: Use Intl.NumberFormat for currency formatting — it handles commas, decimals, and currency symbols correctly across locales.
Tip: Always provide a placeholder image for listings without photos. Missing images break the visual grid.
Build the Search and Filter System
Create a filter sidebar that lets users search by city, price range, bedrooms, bathrooms, and property type. Store all filter state in URL search params so filtered results are shareable and bookmarkable. The server component reads params and queries Prisma with the appropriate WHERE clauses.
// src/app/listings/page.tsx
import { prisma } from "@/lib/prisma";
import PropertyCard from "@/components/PropertyCard";
import SearchFilters from "@/components/SearchFilters";
import type { Prisma, PropertyType } from "@prisma/client";
interface SearchParams {
city?: string;
minPrice?: string;
maxPrice?: string;
bedrooms?: string;
propertyType?: string;
page?: string;
}
const PAGE_SIZE = 12;
export default async function ListingsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const params = await searchParams;
const page = parseInt(params.page ?? "1", 10);
const where: Prisma.PropertyWhereInput = {
status: "ACTIVE",
...(params.city && {
city: { contains: params.city, mode: "insensitive" },
}),
...(params.minPrice && { price: { gte: parseInt(params.minPrice) } }),
...(params.maxPrice && {
price: { ...( params.minPrice ? { gte: parseInt(params.minPrice) } : {}), lte: parseInt(params.maxPrice) },
}),
...(params.bedrooms && {
bedrooms: { gte: parseInt(params.bedrooms) },
}),
...(params.propertyType && {
propertyType: params.propertyType as PropertyType,
}),
};
const [properties, total] = await Promise.all([
prisma.property.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
}),
prisma.property.count({ where }),
]);
const totalPages = Math.ceil(total / PAGE_SIZE);
return (
<main className="px-6 py-10 md:px-16">
<h1 className="text-4xl font-bold text-white">Property Listings</h1>
<p className="mt-2 text-gray-400">
{total} {total === 1 ? "property" : "properties"} found
</p>
<div className="mt-8 flex flex-col gap-8 lg:flex-row">
<aside className="w-full lg:w-72">
<SearchFilters />
</aside>
<div className="flex-1">
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{properties.map((property) => (
<PropertyCard key={property.id} property={property} />
))}
</div>
{totalPages > 1 && (
<div className="mt-8 flex justify-center gap-2">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<a
key={p}
href={`?${new URLSearchParams({ ...params, page: String(p) })}`}
className={`rounded-lg px-4 py-2 text-sm ${
p === page
? "bg-blue-600 text-white"
: "border border-gray-700 text-gray-400 hover:border-blue-500"
}`}
>
{p}
</a>
))}
</div>
)}
</div>
</div>
</main>
);
}Tip: Use URL search params for filter state — this makes every filtered view a unique, shareable URL that search engines can index.
Tip: Run count and findMany in Promise.all to execute both database queries concurrently instead of sequentially.
Build the Filter Sidebar Component
Create the client-side filter form that updates URL search params on submit. The form includes inputs for city search, price range sliders, bedroom count, and property type select. Using a form with method GET and the useRouter hook from Next.js lets you update filters without a full page reload.
// src/components/SearchFilters.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, type FormEvent } from "react";
const propertyTypes = [
{ value: "", label: "All Types" },
{ value: "HOUSE", label: "House" },
{ value: "CONDO", label: "Condo" },
{ value: "TOWNHOUSE", label: "Townhouse" },
{ value: "APARTMENT", label: "Apartment" },
{ value: "LAND", label: "Land" },
];
export default function SearchFilters() {
const router = useRouter();
const searchParams = useSearchParams();
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const params = new URLSearchParams();
formData.forEach((value, key) => {
if (value) params.set(key, value.toString());
});
router.push(`/listings?${params.toString()}`);
},
[router]
);
return (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-gray-800 bg-gray-900/50 p-6"
>
<div>
<label className="text-sm font-medium text-gray-300">City</label>
<input
name="city"
defaultValue={searchParams.get("city") ?? ""}
placeholder="Search by city..."
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium text-gray-300">Min Price</label>
<input
name="minPrice"
type="number"
defaultValue={searchParams.get("minPrice") ?? ""}
placeholder="$0"
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-300">Max Price</label>
<input
name="maxPrice"
type="number"
defaultValue={searchParams.get("maxPrice") ?? ""}
placeholder="No max"
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-300">Bedrooms (min)</label>
<select
name="bedrooms"
defaultValue={searchParams.get("bedrooms") ?? ""}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-white focus:border-blue-500 focus:outline-none"
>
<option value="">Any</option>
{[1, 2, 3, 4, 5].map((n) => (
<option key={n} value={n}>{n}+</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium text-gray-300">Property Type</label>
<select
name="propertyType"
defaultValue={searchParams.get("propertyType") ?? ""}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-white focus:border-blue-500 focus:outline-none"
>
{propertyTypes.map((type) => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
<button
type="submit"
className="w-full rounded-lg bg-blue-600 py-3 font-medium text-white hover:bg-blue-500 transition-colors"
>
Search Properties
</button>
</form>
);
}Tip: Use defaultValue from searchParams so filters persist when the user navigates back to the listings page.
Tip: Add a 'Clear Filters' button that resets the form and navigates to /listings without params.
Add an Interactive Map with Property Markers
Create a map view that shows property locations as markers. When a user hovers over a listing card, the corresponding map marker highlights, and vice versa. This split-view pattern is standard in real estate apps and helps buyers understand the geographic distribution of listings.
// src/components/PropertyMap.tsx
"use client";
import { useState, useCallback } from "react";
import {
APIProvider,
Map,
AdvancedMarker,
InfoWindow,
} from "@vis.gl/react-google-maps";
import type { Property } from "@prisma/client";
interface PropertyMapProps {
properties: Property[];
activeId?: string;
onMarkerHover?: (id: string | null) => void;
}
export default function PropertyMap({
properties,
activeId,
onMarkerHover,
}: PropertyMapProps) {
const [selectedProperty, setSelectedProperty] = useState<Property | null>(
null
);
const center = properties.length > 0
? {
lat:
properties.reduce((sum, p) => sum + p.latitude, 0) /
properties.length,
lng:
properties.reduce((sum, p) => sum + p.longitude, 0) /
properties.length,
}
: { lat: 39.8283, lng: -98.5795 };
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const handleMarkerClick = useCallback((property: Property) => {
setSelectedProperty(property);
}, []);
return (
<div className="h-[500px] overflow-hidden rounded-xl border border-gray-800">
<APIProvider apiKey={process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!}>
<Map
defaultCenter={center}
defaultZoom={11}
mapId="real-estate-map"
gestureHandling="cooperative"
>
{properties.map((property) => (
<AdvancedMarker
key={property.id}
position={{
lat: property.latitude,
lng: property.longitude,
}}
onClick={() => handleMarkerClick(property)}
onMouseEnter={() => onMarkerHover?.(property.id)}
onMouseLeave={() => onMarkerHover?.(null)}
>
<div
className={`rounded-full px-2 py-1 text-xs font-bold shadow-lg transition-all ${
activeId === property.id
? "scale-125 bg-blue-500 text-white"
: "bg-white text-gray-900"
}`}
>
{formatter.format(property.price)}
</div>
</AdvancedMarker>
))}
{selectedProperty && (
<InfoWindow
position={{
lat: selectedProperty.latitude,
lng: selectedProperty.longitude,
}}
onCloseClick={() => setSelectedProperty(null)}
>
<div className="max-w-[200px]">
<p className="font-bold">{selectedProperty.title}</p>
<p className="text-sm text-gray-600">
{selectedProperty.bedrooms} bd | {selectedProperty.bathrooms} ba |{" "}
{selectedProperty.sqft.toLocaleString()} sqft
</p>
<p className="mt-1 font-bold text-blue-600">
{formatter.format(selectedProperty.price)}
</p>
</div>
</InfoWindow>
)}
</Map>
</APIProvider>
</div>
);
}Tip: Show the formatted price on each marker instead of a generic pin — this is the most important data point for buyers scanning a map.
Tip: Calculate the map center from the average lat/lng of visible properties so the map always frames the current results.
Build the Property Detail Page with Image Gallery
Create a dynamic detail page for each listing with a full image gallery, property details, features list, and location map. Use generateStaticParams to pre-render popular listings at build time for instant loads. The gallery cycles through images with keyboard navigation and shows a thumbnail strip below.
// src/app/listings/[slug]/page.tsx
import { notFound } from "next/navigation";
import Image from "next/image";
import { prisma } from "@/lib/prisma";
import type { Metadata } from "next";
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const property = await prisma.property.findUnique({ where: { slug } });
if (!property) return {};
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
return {
title: `${property.title} | ${formatter.format(property.price)}`,
description: `${property.bedrooms} bed, ${property.bathrooms} bath ${property.propertyType.toLowerCase()} in ${property.city}, ${property.state}. ${property.sqft.toLocaleString()} sqft.`,
openGraph: {
images: property.images[0] ? [{ url: property.images[0] }] : [],
},
};
}
export default async function PropertyPage({ params }: Props) {
const { slug } = await params;
const property = await prisma.property.findUnique({ where: { slug } });
if (!property) notFound();
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
return (
<main className="px-6 py-10 md:px-16">
<div className="grid gap-2 md:grid-cols-2">
<div className="relative aspect-[4/3] overflow-hidden rounded-xl">
<Image
src={property.images[0] ?? "/placeholder-property.jpg"}
alt={property.title}
fill
className="object-cover"
priority
/>
</div>
<div className="grid grid-cols-2 gap-2">
{property.images.slice(1, 5).map((img, i) => (
<div
key={i}
className="relative aspect-[4/3] overflow-hidden rounded-xl"
>
<Image
src={img}
alt={`${property.title} photo ${i + 2}`}
fill
className="object-cover"
/>
</div>
))}
</div>
</div>
<div className="mt-8 grid gap-8 lg:grid-cols-3">
<div className="lg:col-span-2">
<h1 className="text-3xl font-bold text-white">{property.title}</h1>
<p className="mt-1 text-gray-400">
{property.address}, {property.city}, {property.state}{" "}
{property.zipCode}
</p>
<div className="mt-4 flex gap-6 text-lg text-gray-300">
<span>{property.bedrooms} Bedrooms</span>
<span>{property.bathrooms} Bathrooms</span>
<span>{property.sqft.toLocaleString()} sqft</span>
{property.yearBuilt && <span>Built {property.yearBuilt}</span>}
</div>
<p className="mt-6 leading-relaxed text-gray-400">
{property.description}
</p>
{property.features.length > 0 && (
<div className="mt-8">
<h2 className="text-xl font-semibold text-white">Features</h2>
<ul className="mt-4 grid grid-cols-2 gap-2">
{property.features.map((feature) => (
<li
key={feature}
className="flex items-center gap-2 text-gray-400"
>
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
{feature}
</li>
))}
</ul>
</div>
)}
</div>
<aside className="rounded-xl border border-gray-800 bg-gray-900/50 p-6">
<p className="text-3xl font-bold text-white">
{formatter.format(property.price)}
</p>
<p className="mt-1 text-sm text-gray-400">
Est. {formatter.format(Math.round(property.price / 360))}/mo
</p>
<button className="mt-6 w-full rounded-lg bg-blue-600 py-3 font-medium text-white hover:bg-blue-500 transition-colors">
Schedule a Tour
</button>
<button className="mt-3 w-full rounded-lg border border-gray-700 py-3 font-medium text-gray-300 hover:border-blue-500 transition-colors">
Contact Agent
</button>
</aside>
</div>
</main>
);
}Tip: Use the priority prop on the main listing image so it loads immediately without lazy-loading delay.
Tip: Generate a rough monthly payment estimate (price / 360 for a 30-year mortgage) — buyers always want to see the monthly cost.
Seed the Database with Sample Listings
Create a seed script that populates your database with realistic sample listings for development and demo purposes. Use varied property types, price ranges, and locations to test the filter and map functionality. Prisma's createMany makes batch insertion efficient.
// prisma/seed.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const sampleProperties = [
{
title: "Modern Downtown Condo",
slug: "modern-downtown-condo",
description:
"Stunning 2-bedroom condo in the heart of downtown with floor-to-ceiling windows, hardwood floors, and panoramic city views. Walking distance to restaurants, shops, and public transit.",
price: 425000,
address: "456 Oak Avenue, Unit 12B",
city: "Portland",
state: "OR",
zipCode: "97201",
latitude: 45.5152,
longitude: -122.6784,
bedrooms: 2,
bathrooms: 2,
sqft: 1100,
propertyType: "CONDO" as const,
images: ["/seed/condo-1.jpg", "/seed/condo-2.jpg", "/seed/condo-3.jpg"],
features: [
"Floor-to-ceiling windows",
"In-unit laundry",
"Rooftop terrace access",
"One parking spot",
"Concierge service",
],
yearBuilt: 2021,
},
{
title: "Craftsman Family Home",
slug: "craftsman-family-home",
description:
"Beautifully restored 4-bedroom craftsman with original woodwork, updated kitchen with quartz countertops, spacious backyard, and a detached two-car garage.",
price: 675000,
address: "789 Elm Street",
city: "Portland",
state: "OR",
zipCode: "97214",
latitude: 45.5122,
longitude: -122.6537,
bedrooms: 4,
bathrooms: 2.5,
sqft: 2400,
propertyType: "HOUSE" as const,
images: ["/seed/house-1.jpg", "/seed/house-2.jpg"],
features: [
"Original hardwood floors",
"Updated kitchen",
"Fenced backyard",
"Detached garage",
"Covered front porch",
"Finished basement",
],
yearBuilt: 1925,
},
];
async function seed() {
await prisma.property.deleteMany();
await prisma.property.createMany({ data: sampleProperties });
console.log(`Seeded ${sampleProperties.length} properties`);
}
seed()
.catch(console.error)
.finally(() => prisma.$disconnect());Tip: Add the seed command to package.json: "prisma": { "seed": "tsx prisma/seed.ts" } so 'npx prisma db seed' works.
Tip: Use real coordinates from Google Maps for your sample data so the map view looks realistic during demos.
Deploy with Environment Variables
Deploy the application to Vercel with the database connection string and Google Maps API key configured. Run the Prisma migration against your production database and seed it with sample data. Verify that the search, map, and detail pages work correctly on the live URL.
# Push to GitHub
git init
git add .
git commit -m "Real estate listing site with search, map, and detail pages"
gh repo create real-estate-site --public --push
# Deploy to Vercel
npm install -g vercel
vercel
# Set environment variables
vercel env add DATABASE_URL
vercel env add NEXT_PUBLIC_GOOGLE_MAPS_KEY
# Run migrations on production database
npx prisma migrate deploy
# Deploy to production
vercel --prodTip: Use Vercel's preview deployments to test database migrations before running them against production.
Tip: Set up a separate database for preview environments so pull request previews don't write to your production data.
Next Steps
- →Add a mortgage calculator component on each listing page with adjustable down payment and interest rate.
- →Integrate Mapbox GL JS for a more customizable map with drawing tools for area-based search.
- →Add saved listings functionality with user authentication so buyers can bookmark properties.
- →Connect to an MLS data feed for real-time property listings instead of manually entered data.