What We're Building
We're building an AI-powered property matching system that revolutionizes how buyers find their dream homes. Instead of endless filter combinations and keyword searches, users can describe what they want in natural language: "A modern loft with natural light near coffee shops, good for a remote worker with a dog."
The system uses OpenAI embeddings to convert property listings and user preferences into semantic vectors, then uses pgvector in Supabase to find the closest matches. Claude enhances the experience by generating compelling, personalized property descriptions that highlight why each match fits the buyer's needs.
Semantic Matching
Vector similarity finds properties that match intent, not just keywords
Natural Language Input
Users describe preferences conversationally instead of using filters
AI Descriptions
Claude generates personalized descriptions for each match
Smart Alerts
Save searches and get notified when matching properties appear
Prerequisites
Before starting this guide, make sure you have the following:
- Node.js 18+ installed on your machine
- OpenAI API key with access to text-embedding-3-small model
- Anthropic API key for Claude API access
- Supabase account (free tier works fine for development)
- Basic TypeScript/React knowledge - familiarity with Next.js App Router is helpful
- Understanding of SQL basics - we'll work with database schemas
Tech Stack Specification
Here's the technology stack optimized for building a production-ready property matcher:
| Layer | Technology | Why This Choice |
|---|---|---|
| Frontend | Next.js 14, Tailwind CSS | Server components for fast property listings UI, streaming for AI responses |
| Backend | Next.js API Routes | Serverless functions scale automatically, built-in TypeScript support |
| Database | Supabase + pgvector | PostgreSQL with native vector similarity search, real-time subscriptions for alerts |
| AI/Embeddings | OpenAI Embeddings | text-embedding-3-small offers great performance at low cost for semantic search |
| AI/Generation | Claude API | Excellent at generating compelling, contextual property descriptions |
| Deployment | Vercel | Zero-config Next.js deployment, edge functions for low latency |
AI Agent Workflow
Here's how to leverage AI tools throughout this build to maximize productivity:
Project Scaffolding with Claude Code
Use Claude Code to generate the initial project structure including the database schema, API routes, and React components. Claude excels at understanding the full context of what we're building.
# Example prompt for Claude Code
Create a Next.js 14 property matcher app with:
- Supabase integration with pgvector for semantic search
- Property schema: id, address, price, bedrooms, bathrooms,
sqft, description, features[], neighborhood, embedding vector
- API routes for: creating embeddings, searching properties,
generating AI descriptions
- React components: PropertyCard, SearchInput, SavedSearches
- Use TypeScript throughout with proper type definitions
UI Generation with v0.dev
Use v0.dev to rapidly prototype the property listing cards and search interface. It excels at creating polished, responsive components that you can customize.
When prompting v0.dev, include specific real estate UI patterns like "property card with image gallery, price badge, and quick-view features" to get industry-appropriate designs.
Development with Cursor
Cursor's AI capabilities shine when implementing the vector search logic and debugging embedding issues. Use Cmd+K to explain complex pgvector queries and generate TypeScript types from your database schema.
Step-by-Step Build Guide
Phase 1: Property Database Schema
First, we'll set up Supabase with pgvector extension and create our property listings table. The schema includes all standard property fields plus a vector column for embeddings.
-- Enable the pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;
-- Create the properties table with vector support
CREATE TABLE properties (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
address TEXT NOT NULL,
city TEXT NOT NULL,
state TEXT NOT NULL,
zip_code TEXT NOT NULL,
price INTEGER NOT NULL,
bedrooms INTEGER NOT NULL,
bathrooms DECIMAL(3,1) NOT NULL,
sqft INTEGER NOT NULL,
property_type TEXT NOT NULL, -- house, condo, townhouse, etc.
description TEXT,
features TEXT[] DEFAULT '{}',
neighborhood_description TEXT,
listing_date TIMESTAMPTZ DEFAULT now(),
images TEXT[] DEFAULT '{}',
-- Vector embedding for semantic search (1536 dimensions for OpenAI)
embedding vector(1536),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Create an index for fast vector similarity search
CREATE INDEX properties_embedding_idx
ON properties
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- Create index for common filters
CREATE INDEX properties_price_idx ON properties(price);
CREATE INDEX properties_city_idx ON properties(city);
CREATE INDEX properties_bedrooms_idx ON properties(bedrooms);
Phase 2: Embedding Generation for Properties
Now we'll create the embedding generation logic. Each property gets converted to a rich text representation, then embedded using OpenAI's text-embedding-3-small model. This captures semantic meaning beyond simple keywords.
import OpenAI from 'openai';
import { createClient } from '@supabase/supabase-js';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
interface Property {
id: string;
address: string;
city: string;
price: number;
bedrooms: number;
bathrooms: number;
sqft: number;
property_type: string;
description: string;
features: string[];
neighborhood_description: string;
}
/**
* Create a rich text representation of a property for embedding.
* This combines all property attributes into a semantic description.
*/
export function createPropertyText(property: Property): string {
const priceRange = property.price < 300000 ? 'affordable'
: property.price < 600000 ? 'mid-range'
: property.price < 1000000 ? 'upscale'
: 'luxury';
const sizeDescription = property.sqft < 1000 ? 'cozy'
: property.sqft < 2000 ? 'spacious'
: property.sqft < 3500 ? 'large'
: 'expansive';
return `
${priceRange} ${property.property_type} in ${property.city}.
${property.bedrooms} bedrooms, ${property.bathrooms} bathrooms.
${sizeDescription} ${property.sqft} square feet.
${property.description || ''}
Features: ${property.features.join(', ')}.
Neighborhood: ${property.neighborhood_description || ''}
`.trim().replace(/\s+/g, ' ');
}
/**
* Generate embedding for a property using OpenAI
*/
export async function generatePropertyEmbedding(
property: Property
): Promise<number[]> {
const text = createPropertyText(property);
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
}
/**
* Generate embedding for a user's search query
*/
export async function generateQueryEmbedding(
query: string
): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: query,
});
return response.data[0].embedding;
}
/**
* Update a property's embedding in the database
*/
export async function updatePropertyEmbedding(
propertyId: string
): Promise<void> {
// Fetch the property
const { data: property, error: fetchError } = await supabase
.from('properties')
.select('*')
.eq('id', propertyId)
.single();
if (fetchError) throw fetchError;
// Generate embedding
const embedding = await generatePropertyEmbedding(property);
// Update the property with the embedding
const { error: updateError } = await supabase
.from('properties')
.update({ embedding })
.eq('id', propertyId);
if (updateError) throw updateError;
}
text-embedding-3-small produces 1536-dimensional vectors. Properties with similar descriptions will have vectors that are "closer" in this high-dimensional space, enabling semantic search that understands meaning rather than just matching keywords.
Phase 3: User Preference Capture (Natural Language)
This is where the magic happens. Users describe their ideal property in natural language, and we convert that to an embedding that can be matched against our property database.
import { NextRequest, NextResponse } from 'next/server';
import { generateQueryEmbedding } from '@/lib/embeddings';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
interface SearchFilters {
minPrice?: number;
maxPrice?: number;
minBedrooms?: number;
city?: string;
}
export async function POST(request: NextRequest) {
try {
const { query, filters, limit = 10 } = await request.json();
if (!query || typeof query !== 'string') {
return NextResponse.json(
{ error: 'Query is required' },
{ status: 400 }
);
}
// Generate embedding from user's natural language query
const queryEmbedding = await generateQueryEmbedding(query);
// Build the vector similarity search with optional filters
const { data: properties, error } = await supabase
.rpc('match_properties', {
query_embedding: queryEmbedding,
match_threshold: 0.5,
match_count: limit,
filter_min_price: (filters as SearchFilters)?.minPrice || 0,
filter_max_price: (filters as SearchFilters)?.maxPrice || 999999999,
filter_min_bedrooms: (filters as SearchFilters)?.minBedrooms || 0,
filter_city: (filters as SearchFilters)?.city || null,
});
if (error) throw error;
return NextResponse.json({
properties,
query,
count: properties?.length || 0
});
} catch (error) {
console.error('Search error:', error);
return NextResponse.json(
{ error: 'Failed to search properties' },
{ status: 500 }
);
}
}
Phase 4: Vector Similarity Search
We need a PostgreSQL function that combines vector similarity with traditional filters. This function uses cosine distance to find the most semantically similar properties.
-- Create a function for semantic property search with filters
CREATE OR REPLACE FUNCTION match_properties(
query_embedding vector(1536),
match_threshold float,
match_count int,
filter_min_price int DEFAULT 0,
filter_max_price int DEFAULT 999999999,
filter_min_bedrooms int DEFAULT 0,
filter_city text DEFAULT NULL
)
RETURNS TABLE (
id uuid,
address text,
city text,
state text,
price int,
bedrooms int,
bathrooms decimal,
sqft int,
property_type text,
description text,
features text[],
neighborhood_description text,
images text[],
similarity float
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
p.id,
p.address,
p.city,
p.state,
p.price,
p.bedrooms,
p.bathrooms,
p.sqft,
p.property_type,
p.description,
p.features,
p.neighborhood_description,
p.images,
-- Calculate cosine similarity (1 - cosine distance)
1 - (p.embedding <=> query_embedding) AS similarity
FROM properties p
WHERE
p.embedding IS NOT NULL
AND 1 - (p.embedding <=> query_embedding) > match_threshold
AND p.price >= filter_min_price
AND p.price <= filter_max_price
AND p.bedrooms >= filter_min_bedrooms
AND (filter_city IS NULL OR p.city ILIKE filter_city)
ORDER BY similarity DESC
LIMIT match_count;
END;
$$;
Phase 5: AI-Enhanced Property Descriptions
Now we use Claude to generate personalized property descriptions that explain why each match fits the buyer's specific needs and preferences.
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
interface Property {
address: string;
city: string;
price: number;
bedrooms: number;
bathrooms: number;
sqft: number;
property_type: string;
description: string;
features: string[];
neighborhood_description: string;
similarity: number;
}
/**
* Generate a personalized property description based on user preferences
*/
export async function generatePersonalizedDescription(
property: Property,
userQuery: string
): Promise<string> {
const prompt = `You are a knowledgeable real estate assistant helping a buyer find their perfect home.
The buyer is looking for: "${userQuery}"
Here's a property that matches their search (${Math.round(property.similarity * 100)}% match):
Property Details:
- Address: ${property.address}, ${property.city}
- Price: $${property.price.toLocaleString()}
- Type: ${property.property_type}
- Size: ${property.bedrooms} bed, ${property.bathrooms} bath, ${property.sqft.toLocaleString()} sqft
- Features: ${property.features.join(', ')}
- Original Description: ${property.description}
- Neighborhood: ${property.neighborhood_description}
Write a compelling 2-3 sentence description that:
1. Highlights why this property specifically matches what the buyer is looking for
2. Mentions the most relevant features based on their query
3. Uses warm, engaging language without being pushy
Do not include the price or address (those are shown separately). Focus on lifestyle fit and unique selling points.`;
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 200,
messages: [{ role: 'user', content: prompt }],
});
return (response.content[0] as { type: 'text'; text: string }).text;
}
/**
* Generate descriptions for multiple properties in parallel
*/
export async function generateDescriptionsForMatches(
properties: Property[],
userQuery: string
): Promise<{ property: Property; personalizedDescription: string }[]> {
const results = await Promise.all(
properties.map(async (property) => ({
property,
personalizedDescription: await generatePersonalizedDescription(
property,
userQuery
),
}))
);
return results;
}
Phase 6: Saved Searches and Alerts
Finally, we implement saved searches that allow users to receive notifications when new properties matching their preferences are listed.
-- Create saved searches table
CREATE TABLE saved_searches (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
query_text TEXT NOT NULL,
query_embedding vector(1536) NOT NULL,
filters JSONB DEFAULT '{}',
email_alerts BOOLEAN DEFAULT true,
last_alert_sent TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Create table to track which properties users have been alerted about
CREATE TABLE search_alerts_sent (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
saved_search_id UUID REFERENCES saved_searches(id) ON DELETE CASCADE,
property_id UUID REFERENCES properties(id) ON DELETE CASCADE,
sent_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(saved_search_id, property_id)
);
-- Function to find new matching properties for saved searches
CREATE OR REPLACE FUNCTION get_new_matches_for_saved_search(
search_id UUID,
match_threshold float DEFAULT 0.6,
max_results int DEFAULT 5
)
RETURNS TABLE (
property_id uuid,
address text,
city text,
price int,
similarity float
)
LANGUAGE plpgsql
AS $$
DECLARE
search_embedding vector(1536);
search_filters jsonb;
BEGIN
-- Get the saved search details
SELECT query_embedding, filters
INTO search_embedding, search_filters
FROM saved_searches
WHERE id = search_id;
RETURN QUERY
SELECT
p.id AS property_id,
p.address,
p.city,
p.price,
1 - (p.embedding <=> search_embedding) AS similarity
FROM properties p
WHERE
p.embedding IS NOT NULL
AND 1 - (p.embedding <=> search_embedding) > match_threshold
-- Only include properties not already sent
AND NOT EXISTS (
SELECT 1 FROM search_alerts_sent sas
WHERE sas.saved_search_id = search_id
AND sas.property_id = p.id
)
-- Apply price filters from saved search
AND p.price >= COALESCE((search_filters->>'minPrice')::int, 0)
AND p.price <= COALESCE((search_filters->>'maxPrice')::int, 999999999)
ORDER BY similarity DESC
LIMIT max_results;
END;
$$;
import { NextRequest, NextResponse } from 'next/server';
import { generateQueryEmbedding } from '@/lib/embeddings';
import { createClient } from '@supabase/supabase-js';
import { auth } from '@/lib/auth';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { name, query, filters, emailAlerts = true } =
await request.json();
// Generate embedding for the search query
const queryEmbedding = await generateQueryEmbedding(query);
// Save the search with its embedding
const { data, error } = await supabase
.from('saved_searches')
.insert({
user_id: session.user.id,
name,
query_text: query,
query_embedding: queryEmbedding,
filters,
email_alerts: emailAlerts,
})
.select()
.single();
if (error) throw error;
return NextResponse.json({ savedSearch: data });
} catch (error) {
console.error('Save search error:', error);
return NextResponse.json(
{ error: 'Failed to save search' },
{ status: 500 }
);
}
}
Common Issues & Solutions
Here are some common issues you might encounter and how to solve them:
If you see "different vector dimensions" errors, ensure your vector column is 1536 dimensions (matching text-embedding-3-small). If using a different model, adjust the column definition accordingly.
If searches are slow with many properties, ensure you've created the IVFFlat index. For production with 100k+ properties, consider using HNSW index instead: CREATE INDEX ... USING hnsw (embedding vector_cosine_ops).
If matches don't seem relevant, improve your property text representation in createPropertyText(). Include more semantic descriptors like "great for families", "walkable neighborhood", or "quiet street" based on property attributes.
Other tips for debugging:
- Test embeddings directly: Use OpenAI's playground to verify your embedding text makes semantic sense
- Log similarity scores: Add console logs to see what similarity scores you're getting - adjust threshold if needed
- Check for null embeddings: Ensure all properties have embeddings generated before searching
- Rate limits: OpenAI has rate limits on embeddings - batch requests and add retry logic for production
Next Steps
Congratulations on building your AI Property Matcher! Here are some ways to extend and improve the system:
- Add hybrid search: Combine vector similarity with keyword search for better results when users mention specific features like "pool" or "granite counters"
- Implement re-ranking: Use Claude to re-rank the top results based on more nuanced understanding of user preferences
- Add user feedback loop: Track which properties users favorite or dismiss to improve future recommendations
- Integrate MLS data: Connect to real MLS/IDX feeds for live property data in your target market
- Build a chatbot: Create a conversational interface where users can refine their search through dialogue
- Add image embeddings: Use CLIP or similar models to match based on property photos as well as descriptions
Follow the Vibe Coding Enthusiast
Follow JD — product updates on LinkedIn, personal takes on X.