Intermediate ~4 hours

Build an AI Property Matcher

Create an intelligent property recommendation system that uses vector embeddings to match buyers with their perfect properties. Transform natural language preferences into semantic searches that understand what buyers really want.

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

User Input Natural Language
→
OpenAI Embeddings
→
Supabase pgvector Search
→
Claude Descriptions

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.

Claude Code Prompt
# 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.

Pro Tip

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.

supabase/migrations/001_create_properties.sql
-- 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.

lib/embeddings.ts
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;
}
Understanding Embeddings

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.

app/api/search/route.ts
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.

supabase/migrations/002_match_properties_function.sql
-- 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.

lib/descriptions.ts
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.

supabase/migrations/003_saved_searches.sql
-- 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;
$$;
app/api/saved-searches/route.ts
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:

Embedding Dimension Mismatch

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.

Slow Vector Searches

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

Poor Match Quality

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