Vibe coding a real estate website: RESO and Repliers reference
Everything an AI builder needs to know to put MLS® listings on a site without leaking keys, breaking compliance or shipping the classic bugs. It covers how the RESO Web API (OData) and the Repliers API work, a field translation table, statuses, paging, photos, maps, caching, IDX vs VOW rules, and prompts you can paste into Lovable or Cursor.
Examples use PropTx AMPRE for RESO and generic boards for Repliers. Environment variables are shown by name only. Nothing here contains a real key.
18 sections100 cards33 copy-paste promptsPress / to filter
For informational purposes only. This is not legal, compliance or professional advice. Do your own research and due diligence, including checking your board/MLS® rules and your data licence terms, before you build anything from it. Read the full disclosure.
01
Start here
Two ways to get MLS® listing data into a site you build with AI. Pick the data source first: filter syntax, IDs, photos, sold-data rules and stats all depend on it.
Basics
RESO Web API vs Repliers at a glance
Also calledRESO vs Repliers, OData vs REST params
RESO Web API is the industry standard way to query MLS® data using OData ($filter, $select, $top…). In Ontario, PropTx serves it as AMPRE at https://query.ampre.ca/odata. Repliers is a listings API with plain query parameters, built-in statistics and an image CDN, at https://api.repliers.io/listings.
images[] paths on the listing, served from cdn.repliers.io
Rooms
Separate /PropertyRooms resource
rooms[] on the listing
Sold data
StandardStatus eq 'Closed' + MlsStatus
status=U + lastStatus=Sld
Stats / aggregates
No reliable live aggregates. Keep a private Postgres snapshot
Built in: statistics=…, aggregates=…, listings=false
Security
The universal rule
The browser calls your server or edge function, never the MLS API. The browser gets only public values, such as VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_KEY (Vite + Supabase), NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY (Next.js), and NEXT_PUBLIC_MAPBOX_TOKEN.
RuleAMPRE_BEARER_TOKEN and REPLIERS_API_KEY (or per-board keys) are server or edge secrets only. Never give them a VITE_ or NEXT_PUBLIC_ prefix.
NoteThe AMPRE token is a static, long-lived bearer issued to the brokerage. There is no OAuth flow. Repliers uses the REPLIERS-API-KEY header on every call, with no OAuth and no bearer.
Basics
Words you will hear
Term
Meaning
RESO Web API
Standard MLS® API built on OData; resources such as /Property, /Media, /PropertyRooms
OData
Query language in the URL: $filter, $select, $orderby, $top, $skip, $count
AMPRE
PropTx's RESO Web API service (query.ampre.ca/odata)
Repliers
Listings API with /listings, statistics, aggregates and an image CDN
Board
The MLS® organisation that owns the data (e.g. PROPTX, ITSO, Naples Area MLS)
IDX
Public listing display. On RESO the gate field is InternetEntireListingDisplayYN
VOW
Virtual Office Website: sold/leased data for registered consumers who accepted terms
ListingKey / ListingId
RESO system key vs the consumer-facing MLS® number
mlsNumber
Repliers' public MLS® number and id
Architecture
Implementation styles (and which to copy)
RESO / OData
Style 1 (recommended)
Style 2
Shape
Server builds OData
Thin proxy, client builds OData
Edge function
Validates params, builds OData, maps rows to an internal Listing model
Pass-through of path + query
Internal model
Normalized Listing type
UI reads raw RESO names
VOW
Pattern A: server-enforced (401 without a valid user JWT)
Often Pattern B: copy only, not enforced
Media
Batched (5 listing keys per /Media call)
Often N+1 (one call per card)
Style 2 is acceptable for prototypes only if you add the hardening: allow-listed paths, a $top cap, no HTTP 200 on failure.
Repliers
Style 1 (recommended)
Style 2
Style 3
Shape
Full server API (Next.js App Router or any SSR framework)
Single edge function with action = search, detail or aggregates
Open pass-through proxy
Keys
One key per board
REPLIERS_API_KEY
REPLIERS_API_KEY
Internal model
Shared normalized Listing via normalize()
Raw Repliers JSON
Raw Repliers JSON
VOW
Pattern A: server-enforced (403 on status=U for non-members)
None
Pattern B: client blur only (anti-pattern)
Style 3 is useful only as a source of recipes (statistics, radius/CMA comps, address-lookup ladder), not as an architecture.
02
Architecture & server-side secrets
Where the key lives, what the browser is allowed to send, and how to avoid turning your proxy into an open relay on your credentials.
RESOArchitecture
Reference architecture: RESO
diagram
Browser (anon/publishable key; plus x-user-jwt when signed in)
│ GET {VITE_SUPABASE_URL}/functions/v1/<listings-function>?...
▼
Supabase Edge Function (Deno) ── holds AMPRE_BEARER_TOKEN (edge secret only)
│ - builds / validates OData
│ - enforces IDX display filter for anonymous callers
│ - verifies x-user-jwt with supabase.auth.getUser() before any Closed data
│ GET https://query.ampre.ca/odata/{Property|Media|PropertyRooms}?...
│ Authorization: Bearer <AMPRE_BEARER_TOKEN> Accept: application/json
▼
PropTx AMPRE OData
The browser sends the Supabase anon/publishable key as both Authorization: Bearer and apikey; the signed-in user's JWT travels separately in x-user-jwt.
Photos can be loaded straight from Media.MediaURL in <img>; a proxy is optional.
Supabase is the example backend; Next.js route handlers, Cloudflare Workers or Express work the same way.
RepliersArchitecture
Reference architecture: Repliers
diagram
Browser / mobile app
│ (short-lived HMAC token from GET /api/token, or Supabase anon key for an edge function)
▼
Server you control (Next.js route handler or Supabase edge function)
│ holds REPLIERS_* keys; picks boards; validates params; checks VOW for status=U
│ GET https://api.repliers.io/listings?... header REPLIERS-API-KEY: <board key>
▼
Repliers API ──► images served from https://cdn.repliers.io/{path}?class=small|medium|large
Send REPLIERS-API-KEY: <key> on every Repliers call, plus Content-Type: application/json (or Accept: application/json).
Keys are server-only. The browser never calls api.repliers.io.
Security
Environment variable names (never values)
Name
Where
Purpose
AMPRE_BEARER_TOKEN
Edge secret
Upstream RESO bearer. If missing, return HTTP 500 { error: "AMPRE_BEARER_TOKEN not configured" }
REPLIERS_API_KEY / REPLIERS_KEY_<BOARD>
Server secret
Repliers key(s), one per board on multi-board sites
RecommendationRead these from the environment at every call site. Don't hardcode the backend URL/key into vite.config.tsdefine, and keep .env.example names identical to what the code reads. A common failure is .env.example using NEXT_PUBLIC_* names that a Vite app never reads, so changing .env silently does nothing.
RepliersSecurity
Protecting your own API (in front of Repliers)
Option
Browser → server auth
Notes
HMAC token
Authorization: Bearer <hmac> from GET /api/token (HMAC-SHA256 of the unix timestamp with INTERNAL_API_SECRET, "{unixSeconds}.{hex}", TTL 30 min, cached in sessionStorage), or x-internal-secret server-to-server
If /api/token is unauthenticated this is anti-scrape friction, not a security boundary. Protect every route (stats, neighbourhoods) consistently.
Supabase edge function
Supabase anon key as Authorization: Bearer + apikey
Set verify_jwt explicitly in config.toml
Generic proxy
apikey + Authorization: Bearer {session token or anon key}
Anti-pattern if it has verify_jwt = false and forwards any subpath and method: an open proxy on your key
SecurityBug
Don't build an open relay
Anti-patternA pass-through with verify_jwt = false and no caller check is an open AMPRE relay: anyone holding the public anon key can query the feed on your credentials.
If the proxy is generic, allow only /Property, /Media, /PropertyRooms (or listings on Repliers).
Cap $top (e.g. 200) / pageSize; reject $expand and unexpected query keys; require a caller check.
Internal service secrets that bypass the VOW gate must stay server-to-server. A public anti-scrape token must not bypass it.
RESOBug
Transport rules for AMPRE
Encode spaces as %20, never +. AMPRE rejects + inside $filter / $orderby. Build with encodeURIComponent; if you proxy, rewrite any + in the raw query to %20, because the WHATWG URL parser turns %20 into + when re-serializing search params.
Escape OData strings by doubling single quotes (names like L'Amoreaux or O'Connor).
Retries: up to 3 attempts; HTTP ≥ 500 and network errors wait 300 × (attempt + 1) ms; return 4xx immediately. Add a token-bucket limiter if you expect bursts.
Failure shape: never return HTTP 200 { error, fallback: true } when every attempt throws; clients will treat it as data. Return 502 JSON.
03
RESO Web API / OData (PropTx AMPRE)
Resources, OData options, the PropTx-specific restrictions to design around, and copy-ready recipes. Encode spaces as %20 when sending.
RESO
Resources and what they are for
Resource
Used for
Notes
/Property
Lists, detail by key, neighbourhood counts, postal lookup, sync
List queries use $filter, $select, $top, $skip, $orderby, $count=true
/Property('{ListingKey}')
Detail
Send $select={DETAIL_SELECT}; with no $select you get ~300 default fields
/Media
Card photos, detail gallery, CMA comp photos
Separate query; $expand is not needed
/PropertyRooms
Detail room table
$top=100
/$metadata
Debug / schema discovery (CSDL)
/Property?$top=1
Debug: see the default field set
/Member, /Office, /OpenHouse
Agent pages, office rosters, open-house calendars
Optional; not required for a listings site
$apply (groupby/aggregate) exists, but most sites aggregate from a snapshot table instead (see Market stats).
RESOBug
PropTx restrictions to design around
Restriction
Workaround
CloseDate is blocked in $filter (error 1108)
Sold windows filter on ModificationTimestamp ge {cutoff}T00:00:00Z
MLSAreaDistrictToronto cannot be used in $filter (1108)
Scope list queries with CityRegion in (...); derive district for display only
DaysOnMarket is not allowed in $orderby
DOM sorts use ListingContractDate desc/asc
OData groupby on CityRegion ignores in (...) and returns province-wide regions
Page /Property ($top=1000, cap ~8 pages) and count in code
Large Media galleries starve later keys in one OData page
PropertySubType eq '…': Detached, Semi-Detached, Att/Row/Townhouse, Condo Townhouse, Condo Apartment, Common Element Condo, Link, Duplex, Cottage, Vacant Land
Active
StandardStatus eq 'Active'
Sold
StandardStatus eq 'Closed' and (MlsStatus eq 'Sold' or MlsStatus eq 'Sold Conditional')
Leased
StandardStatus eq 'Closed' and (MlsStatus eq 'Leased' or MlsStatus eq 'Leased Conditional')
IDX display (anonymous)
(InternetEntireListingDisplayYN eq null or InternetEntireListingDisplayYN eq true)
Geography
CityRegion in (...), CityRegion eq '…', contains(City,'…')
Keyword
(contains(City,'{q}') or contains(UnparsedAddress,'{q}') or contains(CityRegion,'{q}'))
Price
ListPrice ge {n}, ListPrice le {n} (switch to ClosePrice for closed rows)
Beds / baths / parking
BedroomsTotal ge {n}, BathroomsTotalInteger ge {n}, ParkingTotal ge {n}
Agent
ListAgentKey eq '…' or ListAgentFullName eq '…'
Street (postal lookup)
StreetNumber eq '…' and contains(tolower(StreetName), '…')
Sync window
ModificationTimestamp ge {YYYY-MM-01}T00:00:00Z (not CloseDate)
Useful orderings: ListingContractDate desc (newest), ListPrice asc/desc, ModificationTimestamp desc (default), ListingKey (stable paging for sync and counts).
RESORecipe
Recipe: active list page
OData · all residential
GET {server}/Property
?$top=24
&$skip=0
&$count=true
&$orderby=ModificationTimestamp%20desc
&$filter=(PropertyType%20eq%20'Residential%20Freehold'%20or%20PropertyType%20eq%20'Residential%20Condo%20%26%20Other')
%20and%20TransactionType%20eq%20'For%20Sale'
%20and%20StandardStatus%20eq%20'Active'
&$select=ListingKey,UnparsedAddress,StreetNumber,StreetName,StreetSuffix,City,CityRegion,StateOrProvince,PostalCode,ListPrice,BedroomsTotal,BathroomsTotalInteger,PropertyType,PropertySubType,TransactionType,StandardStatus,MlsStatus,ListAgentFullName,ListOfficeName,BuildingAreaTotal,LivingAreaRange,ParkingSpaces,OriginalEntryTimestamp,ListingContractDate,ModificationTimestamp,DaysOnMarket
GotchaPut every displayed field in $select. A field the card renders but the query omits is silently blank.
RESORecipe
Recipe: detail page (3 calls in parallel)
OData · detail
GET /odata/Property('{ListingKey}')?$select={DETAIL_SELECT}
GET /odata/Media?$filter=ResourceRecordKey eq '{key}' and MediaCategory eq 'Photo'
&$select=MediaKey,MediaURL,MediaCategory,ImageSizeDescription,Order,PreferredPhotoYN,ResourceRecordKey
&$top=200
GET /odata/PropertyRooms?$filter=ListingKey eq '{key}'&$top=100
Run the three with Promise.all. Sort rooms by Order.
RESOCompliance
ListingKey vs ListingId
ListingKey is the stable system key: use it for the id, URL key, favourites key and all /Media / /PropertyRooms joins.
ListingId is the board's consumer-facing MLS® number.
GotchaShowing ListingKey as the "MLS® #" is a common bug. Show ListingId to consumers and keep ListingKey as the route key.
RESOBug
Field names that look right but don't exist in AMPRE
These show up in some RESO client code and generic examples but are not fields in the AMPRE Property entity. Selecting or reading them always yields null.
Name
Closest real AMPRE field(s)
Appliances
(none)
GarageSpaces
GarageParkingSpaces / ParkingSpaces
Heating
HeatType + HeatSource
LivingArea
LivingAreaRange (range string only)
OnMarketDate
(none): use ListingContractDate / OriginalEntryTimestamp
Stories
LegalStories
StoriesTotal
(none)
Field sets can change as PropTx updates the feed. Re-pull $metadata before relying on a rarely used field. Many values are arrays (e.g. Cooling, CommunityFeatures, ArchitecturalStyle): join them for display.
04
Repliers API
Endpoints, the /listings parameter reference, and the quirks that cause most Repliers bugs.
Repliers
Endpoints
Call
Shape
Search
GET https://api.repliers.io/listings?{params}
One listing by path
GET https://api.repliers.io/listings/{mlsNumber}?boardId={id} (validate mlsNumber against /^[A-Za-z0-9]+$/, else 400)
One listing by query
GET /listings?mlsNumber={mls}&status=A (or U) &fields=*,raw
Statistics
GET /listings?...&statistics=...&listings=false (or resultsPerPage=1)
Aggregates
GET /listings?aggregates=address.area,address.city&listings=false&status=A
Locations
GET https://api.repliers.io/listings/locations (place tree; cache ~24 h)
e.g. updatedOnDesc (incremental sync, default browse), createdOnAsc (backfill/sweep)
pageNum, resultsPerPage
page is silently ignored. Treat 100 as the practical page-size cap for bulk walks
fields
*, *,raw, or a list (mlsNumber,agents.agentId,agents.name). raw is omitted unless requested
statistics
e.g. avg-soldPrice,med-soldPrice,avg-daysOnMarket,med-daysOnMarket,cnt-closed,pct-aboveBelowList,grp-mth,cnt-new
aggregates
e.g. address.area,address.city,address.neighborhood
listings
false to return only statistics/aggregates
RepliersBug
Quirks to design around
pageNum, not page. Sending page returns page 1 forever.
Past-the-end pages return HTTP 400 with a body matching /pageNum/i; treat that as an empty page.
Array params are OR (propertyType=Detached&propertyType=Semi-Detached).
status=U ≠ sold. It includes expired, terminated, suspended and leased rows. Add lastStatus=Sld for true sales.
Every statistics query needs type=sale. Otherwise lease rents collapse the averages.
raw. prefix is mandatory for raw-field filters; mixing a raw filter with propertyType ANDs and can return zero unless widened.
Rate limit is shared across board keys on one account: walk boards sequentially in bulk jobs, add ~220 ms between pages and retry 429 up to 5 times (1 s, 2 s, 4 s, 8 s, 16 s).
Exact-string geography.city= must match the board's municipality string; a geocoder bbox sent as map bounds is often more reliable.
RepliersRecipe
Recipe: search through one edge function
browser → your function → Repliers
GET {VITE_SUPABASE_URL}/functions/v1/repliers-listings?action=search&pageNum=1&resultsPerPage=24&status=A&sortBy=updatedOnDesc&class=residential,condo&minPrice=500000&maxPrice=750000&city=Toronto&search=king
→ GET https://api.repliers.io/listings?status=A&sortBy=updatedOnDesc&city=Toronto&class=residential&class=condo&search=king&minPrice=500000&maxPrice=750000&pageNum=1&resultsPerPage=24
REPLIERS-API-KEY: <REPLIERS_API_KEY>
RepliersRecipe
Recipe: detail lookups and the status ladder
Repliers · detail
# multi-board: each board in order, first non-empty wins; status=U only for VOW members
GET /listings?mlsNumber={MLS}&status=A&fields=*,raw
# by path
GET /listings/{mlsNumber}?boardId={boardId}
# status ladder (e.g. hydrating saved/CMA cards), stop at the first non-empty
/listings?mlsNumber={mls}&status=A
/listings?mlsNumber={mls}&status=U
/listings?mlsNumber={mls}&status=U&lastStatus=Ter
/listings?mlsNumber={mls}&status=U&lastStatus=Exp
/listings?mlsNumber={mls}&status=U&lastStatus=Sus
/listings?mlsNumber={mls}
RepliersRecipe
Recipe: radius comparables (CMA)
Repliers · comps
GET https://api.repliers.io/listings
?resultsPerPage=30&pageNum=1&hasImages=true
&lat={lat}&long={lng}&radius={1.5|2.5|3.0|5.0}
&status=U&lastStatus=Sld&type=sale
&minSoldDate={YYYY-MM-DD}
&propertyType={mapped type}&class={residential|condo}
&minBeds={n}&maxBeds={n}
&minSqft={n}&maxSqft={n} # omitted on the widest tier
Tiers (stop at the first with ≥ 6 rows): tight 1.5 km / 6 months / ±15% sqft / ±1 bed; broad 2.5 / 9 / ±20% / ±1; wider 3.0 / 12 / ±30% / ±2; widest 5.0 / 18 / none / ±2. Run a same-building tier first when there is a unit (streetNumber, streetName, city, 24 months, beds ±1); ≥ 3 rows replaces the radius search.
RepliersRecipe
Recipe: address match ladder
Repliers · address
1) /listings?resultsPerPage=10&pageNum=1&streetNumber={n}&streetName={normalized}&city={city}&unitNumber={unit}
2) same without unitNumber
3) /listings?resultsPerPage=10&pageNum=1&streetName={normalized}&city={city}&lat={lat}&long={lng}&radius=0.15
No status filter. Normalize suffixes first (street→st, avenue→ave, road→rd, drive→dr, boulevard→blvd, crescent→cres), in every call that uses street fields.
RepliersArchitecture
Multi-board fan-out
id
Market
Env var (example)
Attribution rule (example)
board_a (e.g. PROPTX)
ontario
REPLIERS_KEY_BOARD_A
agent (IDX/VOW agents[] is often empty)
board_b (e.g. ITSO)
ontario
REPLIERS_KEY_BOARD_B
brokerage-only: display the brokerage name, hide agent names
board_c (e.g. Naples Area MLS)
florida
REPLIERS_KEY_BOARD_C
agent
Fan out with Promise.allSettled; a failed board goes into _meta.error. Concatenate in board order; dedupe by mlsNumber across boards (with several boards use board + mlsNumber as the key).
Page 2 = page 2 of each board; totalCount = sum; hasMore if any board has more. Sorting across boards is only correct on what has loaded.
Per-board property-type maps: a PROPTX-style board uses propertyType; an ITSO-style board uses raw.ITSO_PropertyAttached=Detached / Semi Detached (no hyphen); a Naples-style board uses lowercase raw.PropertySubType. Skip a board that can't satisfy a selected type.
RepliersSearch
Own-brokerage-first ordering
Approach
Mechanism
Caveat
Two-query merge
Brokerage-filtered and unfiltered searches in parallel, dedupe on ${mlsNumber}-${resource}, priority first, slice the page
Page 5 at 24/page pulls 120 + 144 rows every time; pager can open empty pages
In-page sort
Dedupe by mlsNumber (first wins), then stable-sort own-brokerage rows first inside the page
Cross-board copies collapse
Featured panel
Fetch ~27 rows, own-brokerage first then listDate desc, drop near-duplicate addresses, show 9
RecommendationMatch the brokerage the same way the API filters it (exact string), or normalize both sides. Page the merge with a real cursor, or accept "own brokerage first" as a sort within each page.
05
Field translation table
The same idea under two names. Map raw fields into one normalized Listing type on the server; the browser never sees raw RESO or raw Repliers JSON.
Fields
RESO ↔ Repliers ↔ what you display
Also calledfield mapping, normalize(), transformListing
Display
RESO field(s)
Repliers path(s)
id / route key
ListingKey
${board.id}-${mlsNumber} (or mlsNumber on one board)
office.brokerageName or agents[0].officeName; co-list from agents[].brokerage.name + raw.CoListOfficeName/3/4
Gotcha$select discipline. A mapper that reads Latitude, Longitude, YearBuilt, ParkingType1/2 without selecting them gets empty values every time. Generate $select from the same field list the mapper reads.
GotchaRanges.Number("700-799") is NaN. Parse sqft ranges explicitly (low end or midpoint). Rooms: RESO ft↔m uses × 0.3048 when RoomLengthWidthUnits starts with "f"; Repliers rooms are treated as metres, ft = m × 3.281.
Fields
Response envelopes
RESO / OData
List: read @odata.count with $count=true. Your function returns { listings, page, numPages, count }.
raw holds board-native (often RESO-named) fields passed through from the source board. It is present only when fields includes raw, and filtering on a raw field requires the raw. prefix.
ITSO (Ontario): raw.ITSO_PropertyAttached with values Detached, Semi Detached.
PROPTX (Ontario): raw.PropertySubType, raw.CoListOfficeName, raw.CoListOfficeName3, raw.CoListOfficeName4. IDX agents[] is often empty, so co-list office names are the fallback.
Naples Area MLS (Florida): lowercase raw.PropertySubType values.
RecommendationTo see the full shape for your account, call GET /listings/{mlsNumber}?fields=*,raw server-side for one listing per board.
06
Statuses: active vs off-market vs sold
A frequent data bug: treating off-market ("not active") as sold.
Status
Status models side by side
Meaning
RESO / AMPRE
Repliers
Active
StandardStatus eq 'Active'
status=A (default)
Off-market / unavailable
StandardStatus eq 'Closed' plus MlsStatus
status=U: includes expired (Exp), terminated (Ter), suspended (Sus), leased (Lsd) and sold
Sold
StandardStatus eq 'Closed' and (MlsStatus eq 'Sold' or MlsStatus eq 'Sold Conditional')
status=U&lastStatus=Sld
Leased
StandardStatus eq 'Closed' and (MlsStatus eq 'Leased' or MlsStatus eq 'Leased Conditional')
lastStatus=Lsd
Price to show
ClosePrice on closed rows
soldPrice (with soldDate)
Anti-patternOn Repliers, status=U is not "sold". A public "Show Sold" that sends status=U without lastStatus=Sld shows expired and terminated listings too. Add lastStatus=Sld in stats, CMA comps, "recently sold" widgets, digests and toggles alike.
StatusUI
Badge rules
Badge
RESO
Repliers
For Sale / For Lease
TransactionType contains "lease"
type contains lease or rent
Sold / Leased
StandardStatus Closed or MlsStatus Sold/Leased
lastStatus / status matches sld, sold, u, closed, leased
New
DaysOnMarket 0 (or ≤ 7), else "{dom}d"
daysOnMarket <= 0 or listDate within 24 h, and not sold
StatusCompliance
Sold is VOW data
Sold and leased rows (prices, dates) are only for registered consumers who accepted the VOW terms, and must be withheld on the server. See Compliance: IDX vs VOW.
RuleCheck VOW access before calling the MLS API. Anonymous status=U / Closed requests get 401 or 403 with no soldPrice or ClosePrice in the body.
07
Paging & search
Filters both APIs understand, how to page, and the UI behaviours that make search feel solid.
numPages = ceil(@odata.count / pageSize). If count is missing and the page was full, fall back to page + 1.
Bulk walks (sync, neighbourhood counts) use $top=1000 with $skip, ordered by ListingKey; cap pages (e.g. 30 for sync ≈ 30k rows, 8 for counts).
Repliers
pageNum + resultsPerPage. page is ignored.
A page past the end returns 400 mentioning pageNum: treat as empty.
Bulk walks: page size 100, ~220 ms between pages, 429 backoff 1, 2, 4, 8, 16 s.
GotchaPost-filtering breaks counts. Any filter applied after $top/$skip (e.g. sqft or "deal" post-filters) leaves count and numPages at the pre-filter value and makes pages look empty. Filter on the server.
Paging style
Page size
Notes
Previous / Next
12
Simplest. Show the total count from the API.
Numbered pages
24
All pages if ≤ 7; otherwise first, last, current ±1, with ellipses. Scroll to top on change.
Load more
24 (150 in map view)
Appends pageNum+1. Show {loaded} of {total}.
Search
Filter params side by side
Filter
RESO $filter
Repliers param
Text search
contains(City,'{q}') or contains(UnparsedAddress,'{q}') or contains(CityRegion,'{q}')
search={q} (no searchFields)
Min / max price
ListPrice ge / le (ClosePrice for sold)
minPrice / maxPrice
Beds
BedroomsTotal ge {n}
minBeds / maxBeds (combined count, 3+1 = 4)
Baths
BathroomsTotalInteger ge {n}
minBaths / maxBaths
Parking
ParkingTotal ge {n}
not seen in the reference implementations
Sale vs lease
TransactionType eq 'For Sale' / 'For Lease'
type=sale / type=lease
Property type
PropertySubType eq '…'; category via PropertyType
propertyType=…, class= (repeat = OR); raw. for board fields
Server-side validation that worked well: page ≥ 1, pageSize clamped to 10–200, type ∈ sale/lease, status ∈ A/U, class ∈ residential/condo/commercial, city and neighborhood ≤ 120 characters with no <>;, postal code ≤ 20. Invalid bounds return 400.
SearchUI
Search UI behaviours
URL sync: write every filter and the page number to the query string and restore them on load (two-way, not inbound-only).
Debounce: 350 ms on text, 600 ms on price inputs, 500 ms on map moves. Reset to page 1 on any filter change.
Sold toggle: a separate button. When signed out it opens the login/register modal and does not send the request.
Rent mode sends type=lease and switches Sold off. Clear all resets every filter and the page.
Keep one neighbourhood whitelist shared by the server and the router so links never 404.
GotchaDead filters and mismatched sorts. One implementation sent a property-type param the server never read; another sent Repliers-style sort names to a RESO function, so every sort silently fell back to the default. Test that every control changes the result set.
Search
Autocomplete / typeahead options
Option
How it works
Use when
Local catalog + Repliers search= fallback
GET /api/listings/search?q=&limit=8 (2–120 chars, limit 1–15) against a Postgres listing_catalog of active listings; fall back to live search= if the table is missing or empty. Rank exact MLS > MLS prefix > address prefix > contains.
Listings in the dropdown
Mapbox places
Geocoder with types=place,postcode,neighborhood,locality&limit=4; skip when the input looks like an MLS number. Picking a place applies its bbox as map bounds.
Place search
Snapshot street suggest (RESO)
Query your private Postgres snapshot. No live AMPRE call per keystroke. Min 2 chars, 300 ms debounce.
Address capture on RESO
Text contains (RESO)
The search box becomes contains on City / UnparsedAddress / CityRegion, debounced 350 ms.
A simple RESO search box
GotchaCatalogs are often active-only, so sold or leased addresses won't autocomplete.
08
Photos & media
Fast, correct photos on cards and detail pages, with no MLS® credentials in the browser.
Photos
Photo rules side by side
Topic
RESO /Media
Repliers CDN
Source
GET /odata/Media?$filter=ResourceRecordKey in (…) and MediaCategory eq 'Photo'
images[] paths on the listing
Size
ImageSizeDescription eq 'Largest'
?class=medium (cards), ?class=large (lightbox), ?class=small (mini cards), or ?w=800
Cover photo
PreferredPhotoYN (true, "true", "Y", 1), then lowest Order
images[0]
Batching
Chunks of 5 keys per call, $orderby=ResourceRecordKey,Order, $top=800
Not needed; images come with the row
Caps
Cards: 5. Detail: all Largest ($top=200)
Up to 20
Absolute vs relative
MediaURL is a full https URL
Prefix https://cdn.repliers.io/ unless the string already starts with http (one shared helper)
Proxy
Not needed. An optional image proxy must allow-list https://*.ampre.ca/ only, send no bearer, and set Cache-Control: public, max-age=3600
Not needed. With Next.js, add cdn.repliers.io to next/image remote patterns
Storage
Don't store photos (licensing and freshness)
Don't store photos. A saved-listing snapshot may keep one image URL
Fallback
Try the next URL on error, then a placeholder. Drop broken URLs from the gallery
Placeholder image or camera icon. Don't ship stock-photo fallbacks
RESOPhotosRecipe
Recipe: batched card photos
OData · 5 keys per call
GET /odata/Media
?$filter=ResourceRecordKey in ('k1','k2',...) and MediaCategory eq 'Photo'
&$select=MediaURL,Order,ImageSizeDescription,PreferredPhotoYN,ResourceRecordKey
&$orderby=ResourceRecordKey,Order
&$top=800
GotchaN+1 photos. One /Media call per card means 24 calls per page. A page of n cards should issue ceil(n/5) Media calls.
PhotosUI
Photo UI behaviour
Lazy-load card images (loading="lazy"). Card carousel of up to 5–8 photos with dots.
Detail: thumbnails plus a lightbox with arrow keys and Escape; make the "+N more" overflow clickable.
Photo URLs are public CDN URLs. Never attach the API token to image requests.
09
Maps & geography
Map views, bounds search and place lookups. Only render a map when you have real coordinates.
Maps
Coordinates
RESO / OData
Latitude, Longitude. They must be in $select or the map has nothing to plot. Map-bounds and radius search were not seen in the RESO reference implementations; scope by CityRegion instead.
Repliers
map.latitude, map.longitude. Drop pins that are non-finite or both 0. The query param is long, not lng.
RuleNo map until Latitude and Longitude are in $select (RESO), and no map without real coordinates in general.
When map is set, drop city, neighborhood and postalCode.
Invalid bounds → 400.
Map GL price-pill markers; popup with address/city/brokerage/photo (escape strings); viewport changes debounced 500 ms become bounds; leaving map view clears bounds.
Page size ~150 in map view vs 24 in list view.
Maps
Places and exact-string geography
city= must match the board's municipality string exactly, and some boards file many towns under one municipality name. Picking a place from a geocoder (e.g. Mapbox with NEXT_PUBLIC_MAPBOX_TOKEN) and sending its bbox as map bounds is often more reliable.
Repliers also offers GET /listings/locations (a place tree; cache ~24 h) and aggregates=address.area,address.city for dropdowns.
10
Caching & performance
Numbers that worked in the reference implementations. Cache on your server, and never cache sold data where an anonymous user could read it.
Caching
What to cache, and for how long
What
Setting seen
Repliers /listings responses (server client)
cache 300 s
Repliers /listings/locations place tree
~24 h
Client query cache (TanStack Query)
staleTime 5 minutes for listings, 15 for neighbourhoods
Query keys include the auth identity: ["listing", key, userId] vs "anon". Invalidate ["listing", key] when auth state changes.
If you use SSR loaders, prefetch with anonymous headers only (never a JWT) and dehydrate. Sold HTML is never pre-rendered.
GotchaStale anonymous cache. If listing queries aren't invalidated on sign-in/out, users see the wrong gate state.
CachingArchitecture
Snapshots and catalogs (optional)
RESO / OData
A private Postgres snapshot (RLS on, no public policies) for aggregates and autocomplete only; live pages stay on AMPRE.
Sync job: POST { "months": 3 }, service role or agent only, pages of 1000 ordered by ListingKey (max 30), upsert in batches of 500, never photos.
Sold window uses ModificationTimestamp ge {cutoff} because CloseDate can't be filtered.
Repliers
listing_catalog of active listings for typeahead and sitemaps; public read where status = A; writes via service role.
Cron: sync every 10 min (backfill createdOnAsc, then incremental updatedOnDesc to a watermark), sweep every 6 h. Don't advance the watermark if the page cap is hit.
Photos and agent PII are not stored. Sold listings never enter the catalog, sitemap or typeahead.
RecommendationCommit the schedule in a migration (e.g. pg_cron + pg_net) or document the external scheduler. Don't leave it only in a README.
11
Compliance: IDX vs VOW
Active listings can be public (IDX). Sold and leased data is VOW data: registered users only, enforced on the server, with stored terms acceptance. Always confirm the rules with your own board and data licence.
Compliance
Access matrix
Also calledIDX display rules, VOW gating, sold data behind sign-in
Data
Anonymous
Signed-in VOW user (confirmed email + stored terms)
List 401 before AMPRE is called; detail 401 { vowRequired: true }
Shown
status=U (Repliers)
403{ error: "Sold listing data is available to VOW members only. Please sign in." }
Allowed
Market stats
Aggregates only (no rows)
Same
Compliance
Four approaches seen, and which ones work
Approach
How it works
Verdict
Server 401 on a user JWT (RESO)
Edge function verifies a user JWT header; anonymous closed/hidden requests get 401 before the MLS API is called; SSR uses anonymous credentials
✅ Strong. Gap seen: *any* signed-in user unlocked; email confirmation and terms weren't checked
Server 403 on session + terms flag (Repliers)
Server-only isVowMember() requires a session andprofiles.vow_terms_accepted_at; sold pages are noindex
✅ Strong. Gap seen: status=U without lastStatus=Sld
CSS blur + localStorage flag
Sold prices are in the JSON and the DOM, only blurred; proxy is public
❌ Not a gate. Data leaks
Copy + checkbox only
Terms text exists, but the Sold toggle sends the closed filter for everyone through a public proxy
❌ Unenforced
ComplianceSecurity
The server check
RESO / OData
hasVowAccess(req): read x-user-jwt, verify with supabase.auth.getUser(jwt); require a user, email_confirmed_at, and a stored profiles.tos_accepted_at (+ tos_version). Never trust the anon Authorization header as the user.
Repliers
isVowMember(): cookie session user via auth.getUser()andprofiles.vow_terms_accepted_at non-null; any error → false. Enforce it in every listing route and again inside the shared getListing() helper. The site's HMAC token does not unlock sold data.
Compliance
Registration and stored terms acceptance
Auth modal with a required Terms/VOW checkbox. Expandable VOW text covers the consumer–broker relationship, bona fide interest, personal non-commercial use and board ownership of MLS® data.
Store the acceptance timestamp and version server-side (profiles.tos_accepted_at / tos_version, or set vow_terms_accepted_at from signup metadata terms: "true" in a new-user trigger).
Email confirmation redirects to a noindex confirmation page (e.g. /vow-confirmed) that strips the URL hash.
GotchaTOS lost on email confirmation. If confirmation delays the session, write the terms acceptance on the confirm landing page. 401 redirects home: on a detail 401, open the registration modal instead.
ComplianceRESO
IDX display flags
InternetEntireListingDisplayYN is the IDX gate: null/true is public; false is VOW-only. Anonymous queries add (InternetEntireListingDisplayYN eq null or InternetEntireListingDisplayYN eq true).
InternetAddressDisplayYN: a common gap is selecting it but never applying it. Decide explicitly whether to honour it when building the address, and enforce it or document it as intentionally unused.
Anonymous HTML must never contain a closed listing or a listing with InternetEntireListingDisplayYN=false.
Compliance
Brokerage attribution
Show the listing brokerage on detail: "Listed by {ListOfficeName}" (RESO) or "Listed By (Primary)" / "Co-Listed By" from office.brokerageName, agents[].brokerage.name and raw.CoListOfficeName (Repliers).
Some boards are brokerage-only attribution: display the brokerage name and hide agent names (set agent "" and presentedBy to your brokerage display name).
Never show agent email or cell unless the board allows it.
Site broker vs listing brokerage: if you show your own agent, label them as the presenting broker and still show the listing brokerage.
Compliance
Footer and terms copy (templates)
RESO footer template
Real estate listings, data and market statistics sourced from {BOARD} and PropTx. They are deemed reliable but not guaranteed.
{BROKERAGE identification}. Not intended to solicit buyers or sellers currently under contract.
Repliers footer template
Listing data provided courtesy of {BOARDS} via {BROKERAGE}. Information is deemed reliable but not guaranteed. Not intended to solicit buyers or sellers already under contract. Listing data refreshes every {N} minutes.
Terms include the VOW agreement, board/PropTx copyright and audit right; Privacy allows sharing registration data with PropTx/the board for VOW audit.
VOW terms page: board copyright, personal non-commercial use, any brokerage-only attribution rule, IDX licence text.
Use the MLS® mark when you label the consumer number ("MLS® {ListingId}").
ComplianceSEO
SEO for gated pages
RESO: noindex whenever the server detail fetch fails, including a VOW 401.
Repliers: noindex unless status is A; sold detail pages are noindex.
Sitemaps list active listings only.
12
Listing detail page, leads & favourites
One page per listing with a stable, SEO-friendly URL, and leads that keep the listing context.
Text after the last -- → ListingKey. The double dash keeps keys safe from address hyphens
RESO bare key
/listings/{encodeURIComponent(ListingKey)}
Works, but no SEO address
Repliers address slug + MLS
/listings/123-main-st-X1234567
Last hyphen segment, uppercased. Permanently redirect bare or legacy slugs to the canonical slug
RecommendationAn address slug ending in the lookup ID, plus a permanent redirect to the canonical slug.
DetailUI
Detail page sections
Gallery: large photo + thumbnails, "+N" overflow, lightbox with arrows and Escape (RESO); mobile carousel, desktop mosaic 1 + 4, lightbox at class=large with scroll lock (Repliers).
Header: address H1, area, status badge, price (sold price + SOLD badge with sold date when sold and permitted).
Key stats, remarks, a facts grid that skips empty values, maintenance fee, taxes + year, rooms table with ft/m toggle.
Attribution, Book a Visit / showing request (active only), favourite, Report an Inaccuracy.
Anti-patternNever invent data. No placeholder schools, fake neighbourhood blurbs, or "similar listings" without a real query. Never let a chatbot claim listings are simulated or invent prices.
Leads
Leads and inquiries
Inquiry/showing form → POST /api/inquiry: honeypot field, reject submissions faster than 3 s or older than 6 h, ~15/hour/IP, optional Turnstile.
Store with the service role (RLS on, no public read). Carry the listing ID, address and absolute URL.
Resolve recipients on the server (roster by posted agent IDs, else office region, else a default lead manager from config). Never accept a recipient email from the client; keep addresses in env, not code.
Show success only after the request actually succeeded.
Leads
Favourites / saved listings
saved_listings(user_id, mls_number, address, price, image, beds, baths, slug) with a unique (user_id, mls_number) pair and owner-only RLS, keyed by the same ID as the detail slug.
Snapshot columns let saved cards render after the listing leaves the feed.
Load favourites by their IDs, including sold ones after sign-in, never by intersecting with "the first N active listings".
13
Market stats & aggregates
Repliers has built-in statistics. RESO effectively doesn't, so you aggregate from a private snapshot.
RepliersStats
Repliers statistics
Repliers · statistics
GET /listings?city={city}&propertyType={label}&type=sale&status=U&lastStatus=Sld
&minSoldDate={12 months back}
&statistics=avg-soldPrice,med-soldPrice,avg-daysOnMarket,med-daysOnMarket,cnt-closed,pct-aboveBelowList
&listings=false
# monthly sold
...&statistics=grp-mth,avg-soldPrice,med-soldPrice,cnt-closed,avg-daysOnMarket&listings=false
# monthly new listings
GET /listings?city={city}&propertyType={label}&type=sale&minListDate={date}&statistics=grp-mth,cnt-new&listings=false
GotchaEvery stats call includes type=sale (a big-city median in the low thousands means it was dropped). If a neighbourhood cohort has fewer than 24 sales over 12 months, fall back to city and say so. Label approximations such as sale-to-list derived from aboveBelowList.
RESOStats
RESO: snapshot stats
OData groupby on CityRegion ignored in (…) and CloseDate is blocked in $filter, so there are effectively no reliable live aggregates. Sync to a private snapshot, then serve a public market-stats function that reads only that table.
It returns avgSoldPrice, avgListPrice, avgNewListPrice, avgDOM, totalSold, totalNew, snlr = round(sold / new × 100), marketStatus (under 40 Buyer's, over 60 Seller's, else Balanced) and monthly buckets for 3/6/9/12/18/24 months.
RuleStats endpoints return aggregates only, never raw rows, so public stats don't leak VOW records.
14
Common bugs
The failures that showed up again and again in real implementations.
RESOBug
RESO bugs
+ instead of %20 in $filter / $orderby (AMPRE rejects it; proxies re-serialize %20 to +).
Unescaped single quotes in OData strings.
CloseDate or MLSAreaDistrictToronto in $filter (error 1108); DaysOnMarket in $orderby.
Displayed fields missing from $select, or non-AMPRE names (LivingArea, OnMarketDate, Heating, Stories…) in the mapper.
One /Media call per card instead of batches of 5.
ListingKey shown as the MLS® number.
Token mismatch: the mapper wrote "Yes" while the page checked for "Y", so fee-includes chips never rendered.
Proxy returns HTTP 200 { error, fallback: true } on failure, and it gets rendered as a listing.
RepliersBug
Repliers bugs
Sending page instead of pageNum (page 1 forever).
Treating the past-the-end 400 as an error instead of an empty page.
status=U used as "sold" without lastStatus=Sld.
Stats without type=sale.
Raw filters without the raw. prefix; per-board vocabularies (hyphens, case) ignored.
Beds not computed as numBedrooms + numBedroomsPlus, so minBeds results look wrong.
Number() on a sqft range string.
lng instead of long.
Class compared case-sensitively (residential vs RESIDENTIALPROPERTY).
Currency not set per market (e.g. USD for Florida rows).
UIBug
UI and app bugs
Showing a count larger than what the user can reach (one implementation requested 24 rows, rendered 9, and had no next page).
Slicing rows to a multiple of the column count, which hides listings.
target="_blank" on every card.
Filters not written to the URL.
A form that toasts success without sending anything.
Sold data hidden with CSS blur while the JSON contains it.
README / agent docs describing a framework that isn't actually in the repo.
15
Before you ship
The shared checklist for any new listings site, RESO or Repliers.
Security
Security and data
MLS® credentials exist only as server or edge secrets. A search of the client bundle finds none.
The browser calls only your API or edge function, never query.ampre.ca or api.repliers.io.
The proxy allow-lists paths, caps $top / pageSize, rejects $expand and unexpected params.
A total upstream failure returns 5xx, never HTTP 200 with an error body.
RESO queries encode spaces as %20 and escape quotes.
Compliance
VOW / compliance
Sold and leased data is gated on the server (401/403) by user + confirmed email + terms timestamp. No CSS-only blur.
RESO anonymous queries apply the InternetEntireListingDisplayYN rule. Repliers sold queries use lastStatus=Sld.
Anonymous SSR HTML never contains a closed listing. Sold detail pages are noindex.
Footer: data source/board, "deemed reliable but not guaranteed", brokerage, "not intended to solicit buyers or sellers currently under contract". Terms, Privacy and VOW terms are linked.
The listing brokerage (and co-list brokerage when available) is shown on detail.
Search
Search, grid and detail
Every filter changes the server query. Sort values match the server.
Filters and page sync to the URL and survive refresh. Text and price inputs are debounced; page resets to 1 on filter change.
The count shown equals what the user can reach. No post-filtering after paging.
Loading, empty and error states on every grid.
The slug ends with the lookup ID and non-canonical slugs redirect. Every rendered field is in $select / fields. Consumers see the real MLS® number.
Leads
Leads and ops
Inquiries store the listing ID and address and email server-resolved recipients. The spam guard is on.
Favourites are keyed by the slug ID with owner-only RLS.
Sync and cron schedules are committed, if you rely on a snapshot or catalog.
Repliers bulk walkers: pageNum, page size 100, ~220 ms between pages, 429 backoff.
Sitemap strategy decided (e.g. active listings from the catalog, 10,000 URLs per chunk).
16
Copy-paste prompts: quick builds
One prompt per UI piece, for Lovable, Cursor or any AI builder. Each one keeps the key on the server.
Prompts
How to use these prompts
Replace {…} placeholders with your site's scope (board, brokerage, neighbourhood whitelist, subtypes).
Quick prompts build one UI piece. The build sequences are ordered: give them one at a time, each assumes the previous one is done.
Every prompt keeps the MLS® key server-side. If an AI tool suggests putting a key in a VITE_ or NEXT_PUBLIC_ variable, stop and correct it.
Run the acceptance-check prompt at the end, and read the generated code yourself.
Build a listings filter bar plus a server-side edge function for a RESO OData feed (PropTx AMPRE, base https://query.ampre.ca/odata, Authorization: Bearer from env AMPRE_BEARER_TOKEN, never in the browser).
Controls: text search, For Sale / For Lease, min and max price, min beds, min baths, parking, property subtype, sort (price-asc, price-desc, newest), Sold toggle.
Map them on the server to $filter:
- text: contains(City,'q') or contains(UnparsedAddress,'q') or contains(CityRegion,'q'), escaping single quotes by doubling them
- ListPrice ge/le; BedroomsTotal ge; BathroomsTotalInteger ge; ParkingTotal ge
- TransactionType eq 'For Sale' or 'For Lease'; PropertySubType eq '...'
- active: StandardStatus eq 'Active'; anonymous callers also need (InternetEntireListingDisplayYN eq null or InternetEntireListingDisplayYN eq true)
- sort: price uses ListPrice, newest uses ListingContractDate desc, default ModificationTimestamp desc. Never put DaysOnMarket in $orderby.
Paging: $top, $skip, $count=true. Encode spaces as %20, never +. Do not filter on MLSAreaDistrictToronto or CloseDate.
Write every filter and the page to the URL query string and restore them on load. Debounce text and price inputs by 350 ms. Reset to page 1 on filter change. Sort option values must exactly match what the server accepts.
Build a listings filter bar plus a server route that proxies Repliers (GET https://api.repliers.io/listings, header REPLIERS-API-KEY from env REPLIERS_API_KEY, server only).
Controls: free-text search, Sale / Rent, min and max price, min and max beds, min and max baths, property type multi-select, Sold toggle.
Send: search, type=sale or lease, minPrice, maxPrice, minBeds, maxBeds, minBaths, maxBaths, propertyType (repeat the key for OR), class, status=A by default, pageNum and resultsPerPage (never "page").
Validate on the server: pageSize 10-200, type in sale|lease, status in A|U, city and neighborhood at most 120 characters with no <>; characters.
If map bounds "swLng,swLat,neLng,neLat" are given, send map as a closed GeoJSON ring and omit city, neighborhood, and postalCode.
Rent mode forces Sold off. Write all filters to the URL and restore them on load. Debounce price by 600 ms and map moves by 500 ms.
Build a listings card grid fed by our listings edge function (RESO OData). Page size 12 or 24, numbered pagination from @odata.count, scroll to top on page change.
Server: one /Property call with $top/$skip/$count=true and a $select that includes every field the card shows (ListingKey, ListingId, StreetNumber, StreetName, StreetSuffix, UnitNumber, CityRegion, City, ListPrice, BedroomsTotal, BathroomsTotalInteger, LivingAreaRange, BuildingAreaTotal, TransactionType, StandardStatus, MlsStatus, DaysOnMarket). Then batch /Media for the page's ListingKeys in chunks of 5: ResourceRecordKey in (...) and MediaCategory eq 'Photo', keep ImageSizeDescription 'Largest', put PreferredPhotoYN (true, "true", "Y", 1) first, then Order, max 5 URLs per listing.
Card: photo carousel that advances on error then shows a placeholder, For Sale / For Lease / Sold badge, New badge, CAD price (en-CA, no cents), address, CityRegion, beds (2+1 format), baths, sqft, favourite heart. Link to the listing slug in the same tab.
States: skeleton loading, "No listings match your filters" empty state, visible error box. Never post-filter rows after paging.
Build a listings card grid fed by our /api/listings route (Repliers proxy). Page size 24 (150 in map view). Use a Load more button that requests pageNum+1 and appends, or numbered pages from numPages.
Card: image https://cdn.repliers.io/{images[0]}?class=medium (if the string already starts with http, keep it; placeholder if empty), CAD price, address.streetNumber + streetName + streetSuffix, city, beds = details.numBedrooms + numBedroomsPlus, baths, sqft (show a range if the API returns "min-max"), Sold badge when lastStatus is Sld, New badge when daysOnMarket <= 0, favourite heart.
Show "{loaded} of {total}" while more rows exist. Empty state "No listings match your filters." Dedupe by board + mlsNumber. Never show a count bigger than the user can reach.
Build /listing/:slug for a RESO OData feed. Slug = {street}-{unit}-{beds}bed-{baths}bath-{type}--{ListingKey}; parse the key after the last "--".
Server loader (anonymous credentials only): GET /Property('{key}') with a $select that includes every field rendered below (add Latitude, Longitude, YearBuilt if shown); GET /Media (ResourceRecordKey eq key, MediaCategory eq 'Photo', $top=200, keep Largest, PreferredPhotoYN first, then Order); GET /PropertyRooms (ListingKey eq key, $top=100, sort by Order).
If the listing is Closed or InternetEntireListingDisplayYN is false, the edge function returns 401 vowRequired unless a valid user JWT is sent. On 401, render noindex metadata and open the register modal.
Page: breadcrumb, gallery with thumbnails and a keyboard lightbox, status badge, H1 address, beds/baths/sqft/DOM/parking, PublicRemarks, a facts grid that skips empty values, AssociationFee, TaxAnnualAmount + TaxYear, a room table with an ft/m toggle (x 0.3048), "Listed by {ListOfficeName}", Book a Visit, favourite, Report an Inaccuracy.
Show ListingId as the MLS number; keep ListingKey as the route key. Title, description, og:image = first photo, canonical URL. No map unless Latitude/Longitude are selected. No similar-listings block unless backed by a real query.
Build /listings/[slug] as a server-rendered page. Slug = address-slug + "-" + mlsNumber; parse the last hyphen segment, uppercase it, and permanently redirect non-canonical slugs.
Fetch on the server: GET https://api.repliers.io/listings?mlsNumber={mls}&status=A&fields=*,raw (REPLIERS-API-KEY server-side). Retry with status=U only if the user is a VOW member; otherwise send the user to the grid with an "unavailable" banner.
Gallery: mobile carousel, desktop mosaic (1 large + 4, "+N"), lightbox that swaps class=medium for class=large, arrow keys, Escape, scroll lock.
Sections: price (soldPrice + SOLD badge with soldDate when sold), address, beds/baths/sqft/type, details.description, facts (style, yearBuilt, garage, parking, basement1+2, heating, airConditioning, swimmingPool, taxes.annualAmount, condoFee or HOAFee, lot.width x lot.depth), extras/inclusions/exclusions, rooms with an m/ft toggle (ft = m x 3.281), Listed By / Co-Listed By (office.brokerageName, agents[].brokerage.name, raw.CoListOfficeName), and an inquiry/showing form.
SEO: title "{address}, {city}, {brand}", noindex unless status is A, BreadcrumbList JSON-LD. No map or similar listings unless built on real data.
PromptsBoth
Photo / media handling
Prompt · Lovable / CursorPhoto / media handling
Implement listing photos.
RESO: in the edge function only (AMPRE_BEARER_TOKEN never reaches the browser), fetch /Media with ResourceRecordKey in (up to 5 keys per call) and MediaCategory eq 'Photo', $select MediaURL,Order,ImageSizeDescription,PreferredPhotoYN,ResourceRecordKey, $orderby ResourceRecordKey,Order. Keep ImageSizeDescription 'Largest'. Put PreferredPhotoYN (true, "true", "Y", 1) first, then Order. Dedupe and require https. Return 5 URLs per card and all URLs on detail.
Repliers: build https://cdn.repliers.io/{path}?class=medium for cards and class=large for the lightbox. If the string already starts with http, keep it and add the class param. Cap at 20. Add cdn.repliers.io to next/image remotePatterns if using Next.js.
Both: load photo URLs directly in <img> with no API key. On error, try the next URL, then show a placeholder. Lazy-load card images.
Implement server-enforced VOW gating for sold/leased listings.
1. Auth: email/password (e.g. Supabase). Registration requires a VOW Terms checkbox. Store profiles.tos_accepted_at and tos_version (or set vow_terms_accepted_at from signup metadata terms==="true" in a new-user trigger). Email confirmation redirects to a noindex confirmation page.
2. Server check isVowMember(): valid user (JWT header in an edge function, or cookie session in Next.js), confirmed email, and a non-null terms timestamp. Return false on any error.
3. Listing API: if the request asks for sold data (RESO: StandardStatus 'Closed' or MlsStatus Sold/Leased; Repliers: status=U), or a detail record is closed or has InternetEntireListingDisplayYN=false, return 401 { vowRequired: true } unless isVowMember() is true. Check BEFORE calling the MLS API.
4. Sold filter: RESO StandardStatus eq 'Closed' and (MlsStatus eq 'Sold' or MlsStatus eq 'Sold Conditional'); Repliers status=U&lastStatus=Sld.
5. SSR uses anonymous credentials only. Never render a sold listing into anonymous HTML. Sold detail pages are noindex.
6. Client: when signed out, the Sold toggle opens the register/login modal and sends no request. Include the user ID in query keys and invalidate on auth change. A blur is decoration only, never the gate.
7. Footer: data source/board, deemed reliable but not guaranteed, brokerage name, not intended to solicit buyers or sellers under contract.
Acceptance: an anonymous sold request returns 401/403 with no soldPrice or ClosePrice in the body; a confirmed user who accepted the terms gets the rows.
Build a listing/address typeahead.
UI: debounce 250-300 ms, minimum 2 characters, at most 8 results in two groups (Listings, Places), keyboard navigation. Address-capture forms require picking a suggestion.
Listings (Repliers): GET /api/listings/search?q=&limit=8 (limit clamped 1-15). Search a Postgres listing_catalog table (mls_number prefix plus trigram on address) first. If it is missing or returns nothing, call Repliers /listings?status=A&search={q}&pageNum=1 without searchFields, then retry with the street name only. Rank: exact MLS, MLS prefix, address prefix, address contains. Return { results, source } with Cache-Control private, max-age=15.
Listings (RESO): query our own Postgres snapshot (street_number eq, street_name ilike). Never call live AMPRE per keystroke.
Places: Mapbox geocoding (NEXT_PUBLIC_MAPBOX_TOKEN) with types=place,postcode,neighborhood,locality and limit 4; skip when the input looks like an MLS number. Picking a place applies its bbox as map bounds.
Picking a listing navigates to its canonical detail slug.
Build a market stats panel from Repliers, server-side only (REPLIERS-API-KEY).
Summary: GET /listings?city=&propertyType=&type=sale&status=U&lastStatus=Sld&minSoldDate={12 months ago}&statistics=avg-soldPrice,med-soldPrice,avg-daysOnMarket,med-daysOnMarket,cnt-closed,pct-aboveBelowList&listings=false. Cohorts: city, neighborhood (add neighborhood=), similar (minBeds=maxBeds, minBaths=maxBaths).
Monthly: the same call with statistics=grp-mth,avg-soldPrice,med-soldPrice,cnt-closed,avg-daysOnMarket, plus a new-listings call with minListDate and statistics=grp-mth,cnt-new. Read statistics.soldPrice.mth[YYYY-MM].avg/med and statistics.closed.mth / statistics.new.mth (number or {count}).
Every stats call includes type=sale. Use one shared property-type label map. If the neighbourhood cohort has fewer than 24 sales in 12 months, fall back to city and say so. Label approximations. Return aggregates only.
A RESO OData feed has no reliable live aggregates. Add a sync edge function (service role or agent only) that pages /Property ($top=1000) for Active rows and for Closed Sold/Sold Conditional rows whose ModificationTimestamp falls in the last N months (CloseDate can't be used in $filter). Upsert into a private snapshot table on listing_key (RLS on, no public policies) and log each run.
Add a public market-stats function that reads only that table and returns avgSoldPrice, avgListPrice, avgNewListPrice, avgDOM, totalSold, totalNew, snlr = round(sold/new*100), marketStatus (<40 Buyer's, >60 Seller's, else Balanced), and monthly buckets for months in 3|6|9|12|18|24. Never return raw rows. Schedule the sync with a committed cron job.
PromptsBoth
Leads / inquiry / save listing
Prompt · Lovable / CursorLeads / inquiry / save listing
Add listing leads and favourites.
Inquiry: on the detail page, a form with Inquiry / Request a Showing modes (showing adds date and time). Fields: name, email, phone, message (default "I'm interested in {MLS} at {address}"). POST /api/inquiry with mls, listing id, address, page URL, and any resolved agent IDs. Server: honeypot field, reject submissions faster than 3 s or older than 6 h, 15 per hour per IP, optional Turnstile when TURNSTILE_SECRET_KEY is set. Insert into an inquiries table with the service role (RLS on, no public read). Resolve email recipients on the server (agent roster by ID, else an env-configured default). Never accept a recipient email from the client. Show success only after a 200.
Inaccuracy report: a modal on the detail page that stores lead_type inaccuracy_report with listing_ref "{address} (MLS# {id})".
Favourites: saved_listings(user_id, mls_number, address, price, image, beds, baths, slug, created_at), unique (user_id, mls_number), owner-only RLS, keyed by the same ID as the detail slug. Heart on cards and detail; redirect to login when signed out. The account page lists saved rows from the snapshot (or re-fetches by ID).
17
RESO/AMPRE build sequence
10 ordered prompts that build a complete RESO listings site, ending with acceptance checks. Give them in order.
Build a RESO/AMPRE listings site. Create a Vite + React + TypeScript app (TanStack Start if you want SSR, otherwise React Router) with Tailwind, shadcn/ui, TanStack Query and a Supabase client. Browser env: VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY, read from the environment at every call site — never hardcode them in vite.config.ts define.
Supabase migrations (RLS on everywhere):
- profiles (id = auth.users.id, email, full_name, tos_accepted_at, tos_version) with a trigger that copies full_name from raw_user_meta_data on signup
- favourites (user_id, mls_number text, unique pair, owner-only RLS) — mls_number stores the AMPRE ListingKey
- user_roles with app_role enum 'agent' and has_role()
- leads (lead_type: contact, book_visit, evaluation, guide_download, inaccuracy_report; name, email, phone, address, message, source_url, metadata jsonb, status; agent-only read)
- optional: proptx_listings (listing_key text PK, mls_number, standard_status, mls_status, transaction_type, property_type, property_sub_type, city, district, city_region, postal_code, street parts, unparsed_address, list_price, close_price, bedrooms_total, bathrooms_total, parking_total, living_area_range, list_office_name, days_on_market, listing_contract_date, original_entry_timestamp, modification_timestamp, close_date, raw jsonb, synced_at) with RLS on and NO policies; proptx_sync_runs readable by agents.
After db push, re-grant the standard Supabase anon/authenticated/service_role table grants. AMPRE_BEARER_TOKEN is an edge-function secret only.
PromptsRESO
Prompt 2: AMPRE edge function (server builds OData)
Add a Deno edge function fetch-listings for this RESO/AMPRE listings site. Base URL https://query.ampre.ca/odata. Headers: Authorization: Bearer ${AMPRE_BEARER_TOKEN}, Accept: application/json. Never return the token; if it is missing return 500 JSON.
Encode query params so spaces are %20, never +. Escape OData strings by doubling single quotes. Retry 5xx/network errors up to 3 times with 300ms, 600ms, 900ms delays; return 4xx immediately; if every attempt throws return 502 JSON (never 200).
Always scope Property queries to {SCOPE_FILTER} (e.g. PropertyType eq 'Residential Condo & Other' and CityRegion in ({WHITELIST})). Never put CloseDate or MLSAreaDistrictToronto in $filter (PropTx error 1108). DaysOnMarket is not orderable; dom sorts use ListingContractDate.
Routes (query param route):
- default list: status (A active, U closed), pageNum, resultsPerPage, neighbourhood, listingType sale|rent|lease, minPrice, maxPrice, minBeds, maxBeds, minBaths, parking, propertyType (→ PropertySubType eq), q (→ contains on City, UnparsedAddress, CityRegion), sortBy (price-asc, price-desc, newest, dom-asc, dom-desc; default ModificationTimestamp desc). Map to $filter/$orderby/$top/$skip/$count=true/$select. Generate $select from the same field list the mapper reads.
Anonymous calls: add (InternetEntireListingDisplayYN eq null or InternetEntireListingDisplayYN eq true).
status=U: StandardStatus eq 'Closed' and MlsStatus in Sold/Sold Conditional (sale) or Leased/Leased Conditional (lease); price field becomes ClosePrice. Requires VOW access (Prompt 7) or return 401 { error, vowRequired: true } BEFORE calling AMPRE.
Then batch /Media for the page's ListingKeys in chunks of 5: $filter=ResourceRecordKey in (...) and MediaCategory eq 'Photo', $select MediaURL,Order,ImageSizeDescription,PreferredPhotoYN,ResourceRecordKey, $orderby ResourceRecordKey,Order, $top 800. Keep ImageSizeDescription eq 'Largest', prefer PreferredPhotoYN (true, "true", "Y", 1), then Order, dedupe, https only, max 5 per listing.
Return { listings, page, numPages, count } using @odata.count. Do not post-filter after $top without fixing count.
- route=detail&key={ListingKey}: GET /Property('{key}'), /Media ($top 200, Photo, all Largest), /PropertyRooms ($filter ListingKey eq '{key}', $top 100, sort by Order) in parallel. 401 { vowRequired: true } if the row is Closed or InternetEntireListingDisplayYN is false and the caller lacks VOW access.
- route=neighbourhoods: page Active rows ($top 1000, max 8 pages, $orderby ListingKey) and count by CityRegion in code (OData groupby ignores in()). Return name, slug, saleCount, leaseCount.
Do not use $expand. Verify the caller with supabase.auth.getUser(x-user-jwt); never trust the anon Authorization header as the user.
PromptsRESO
Prompt 3: normalized listing model and client data layer
Prompt · Lovable / CursorRESO/AMPRE build sequence · Prompt 3: normalized listing model and client data layer
Create src/lib/listings.ts with a Listing type: id and listingKey (= ListingKey), mlsNumber (= ListingId when available), address, streetNumber, streetName, unitNumber, neighbourhood (CityRegion), city, price, status Active|Sold|Leased, listingType sale|lease, beds, dens, baths, sqft (+ sqftRange), dom, image, images[], propertyType, description, maintenanceFee, taxes, taxYear, yearBuilt, parkingSpaces, parkingType, exposure, balcony, locker, pets, heating, cooling, basement, exteriorFinish, lat, lng, brokerageName (ListOfficeName), listAgentName (ListAgentFullName), rooms[]. Map in the edge function (transformListing) — the browser never sees raw RESO. Only read field names that exist in AMPRE (no LivingArea, OnMarketDate, Heating, Stories, GarageSpaces, Appliances, StoriesTotal).
Helpers: formatPrice (en-CA, CAD, 0 decimals), formatBeds ("2+1" when dens > 0), slug /listing/{address-slug}-{beds}bed-{baths}bath-{type}--{ListingKey} and extractKeyFromSlug (text after the last --).
Hooks call /functions/v1/fetch-listings with Authorization Bearer + apikey = VITE_SUPABASE_PUBLISHABLE_KEY and, when a session exists, x-user-jwt = session.access_token. Query keys: ["listings", params], ["listing", key, userId or "anon"], ["neighbourhoods"]. staleTime 5 minutes for listings, 15 for neighbourhoods. Invalidate ["listing", key] when auth state changes. Dedupe by listingKey.
If you use SSR loaders, prefetch the same keys with anon headers only (never a JWT) and dehydrate.
Home: hero, a 12-per-page card grid of active listings, and a neighbourhood grid fed by route=neighbourhoods (links must resolve through the same whitelist the router uses).
ListingCard: photo carousel of up to 5 URLs with dots and arrows; on image error advance to the next URL, then a placeholder icon. Badges For Sale / For Lease / Sold / Leased; DOM badge "New" when dom is 0, else "{dom}d". Price CAD, address, neighbourhood, beds (2+1), baths, sqft when > 0, favourite heart (opens auth when signed out), Book a Visit when Active. The whole card links to the slug in the same tab.
Pagination with numbered pages (first, last, current ± 1, ellipses) and the API count. Loading skeletons, inline empty and error states. Media is batched server-side — never one /Media call per card.
Route /listing/$slug. key = extractKeyFromSlug(decodeURIComponent(slug)). Fetch route=detail.
Sections: breadcrumb (Home / neighbourhood / address), gallery (large photo, up to 6 thumbnails, +N overflow, prev/next, lightbox with arrow keys and Escape; drop broken URLs), status badge, H1 address, "{neighbourhood}, {city} · MLS® {mlsNumber}", stat badges (beds, baths, size with sqft/m² toggle, DOM, parking, type, exposure), PublicRemarks, property-details grid (skip empty values), maintenance fee if > 0, annual tax with year if > 0, rooms table with ft/m toggle (0.3048), "Listed by {ListOfficeName}", Report an Inaccuracy, Book a Visit if Active, favourite button.
Fee-includes chips must use the same token the API returns. Do not hardcode a site agent on top of the listing brokerage without labelling them as the site broker. No similar-listings block unless backed by a real query. No map until Latitude and Longitude are in $select.
SEO: title "{address} — {beds}BR {type} {For Sale|For Rent|Sold|Leased} in {neighbourhood} | {SITE}", description with beds/baths/price, og:image = first photo, canonical URL. If the detail loader fails (including 401) render generic noindex metadata.
ListingsFilter: For Sale / For Rent, min and max price, beds 1+..4+, baths 1+..3+, property subtype ({SUBTYPES}), parking 1+/2+, keyword (350 ms debounce), sort with option values exactly price-asc, price-desc, newest. Every control must change the server query (test each one). Write all filter state and the page number to the URL query string and restore it on load. Changing a filter resets to page 1.
Sold/Leased is a separate toggle that sets status=U. If signed out, open the register modal and do not request closed rows.
Supabase email/password auth. Register requires a Terms/VOW checkbox; the expandable VOW text covers the consumer–broker relationship with {BROKERAGE} (e.g. "Acme Realty"), bona fide interest, personal non-commercial use, and {BOARD} ownership of MLS® VOW data. emailRedirectTo {origin}/vow-confirmed; password reset {origin}/reset-password. Store profiles.tos_accepted_at and tos_version once a session exists (on the confirm landing page if confirmation delays the session). /vow-confirmed strips the URL hash, explains sold data and favourites, and is noindex.
In fetch-listings, hasVowAccess(req) = x-user-jwt verifies with supabase.auth.getUser AND email_confirmed_at is set AND profiles.tos_accepted_at is not null. Without it: status=U returns 401 before any AMPRE call; detail returns 401 { vowRequired: true } for Closed rows and InternetEntireListingDisplayYN=false; lists always include the IDX display filter. On a 401 the detail page opens the register modal (do not silently redirect home).
Footer: listings and statistics sourced from {BOARD} and PropTx, deemed reliable but not guaranteed; brokerage identification; not intended to solicit buyers or sellers currently under contract. Terms page includes the VOW agreement, {BOARD}/PropTx copyright and audit consent; Privacy mentions sharing registration data with PropTx/{BOARD} for audit. Anonymous HTML must never contain a closed listing or a listing with InternetEntireListingDisplayYN=false. Decide and document whether InternetAddressDisplayYN is enforced.
Favourites keyed by ListingKey. The account page loads favourites by those keys (detail fetches or a dedicated route), including sold ones for VOW users — never "first 100 active listings".
Edge function send-inquiry: types book-visit, contact, inaccuracy-report, evaluation. Insert leads with the service role (listing address, ListingKey and absolute listing URL in metadata) and email the brokerage via a transactional template. Recipient addresses come from an env var, not code. Book a Visit and "Request a viewing" must carry the ListingKey and address. Mount Report an Inaccuracy on the detail page.
Add sync-proptx-listings (POST { months }, default 3, max 60). Allow service role or the agent role. Pull Active For Sale rows in scope and Closed Sold/Sold Conditional rows with ModificationTimestamp ge the cutoff (UTC midnight on the 1st of the month `months` ago) — never CloseDate in $filter. Pages of 1000 ordered by ListingKey, max 30. Dedupe by listing_key, upsert in batches of 500, store raw $select JSON, never photos. Log proptx_sync_runs. Commit a pg_cron + pg_net daily schedule (service-role bearer, {"months":3}) in a migration, or document the external scheduler.
Add market-stats (GET, public, service-role read only): neighbourhood, months in 3|6|9|12|18|24, optional beds. Return avgSoldPrice, avgListPrice, avgNewListPrice, avgDOM, totalSold, totalNew, snlr (round(sold/new*100)), marketStatus (< 40 Buyer's, > 60 Seller's, else Balanced) and monthly buckets. Never return raw rows.
Verify:
1. AMPRE_BEARER_TOKEN exists only as a server secret; repo and client bundle contain no token value; the browser never calls query.ampre.ca.
2. Anonymous GET fetch-listings?status=U returns 401 and the grid shows no sold prices.
3. Anonymous detail for a known Active IDX listing returns 200 with https MediaURL (Largest) and the SSR HTML (if SSR) contains the listing slug.
4. The same detail for a Closed listing returns 401 and the page head is noindex without the sold price.
5. A confirmed user with TOS stored receives closed listings and InternetEntireListingDisplayYN=false listings; an unconfirmed user does not.
6. propertyType changes the result set; sortBy=price-asc orders by ListPrice; every filter appears in the URL and survives reload.
7. Every neighbourhood link resolves (one whitelist).
8. A page of cards issues ceil(n/5) Media calls, not n.
9. market-stats returns non-zero sold aggregates after a sync and no raw rows.
10. Book a Visit creates a leads row containing the ListingKey; favourites survive reload and include sold favourites after sign-in.
11. If the proxy is generic, a request to any path other than /Property, /Media, /PropertyRooms returns 400, and $top above 200 is rejected.
18
Repliers build sequence
11 ordered prompts that build a complete Repliers listings site, ending with acceptance checks. Give them in order.
Add a server-only Repliers client for this Repliers listings site. Base https://api.repliers.io/listings. Header REPLIERS-API-KEY with the board's key; also Content-Type: application/json. Board table: {BOARDS} (e.g. board_a/ontario/agent → REPLIERS_KEY_BOARD_A, board_b/ontario/brokerage → REPLIERS_KEY_BOARD_B, board_c/florida/agent → REPLIERS_KEY_BOARD_C; optional REPLIERS_KEY_ATTRIBUTION for attribution only). Single-board sites use REPLIERS_API_KEY.
fetchRepliersListings(key, params): translate page → pageNum (Repliers ignores page), default status=A and resultsPerPage=50, emit array values as repeated keys (OR), cache 300 s, and treat HTTP 400 whose body mentions pageNum as an empty page. Keep raw.* keys with the raw. prefix. Never call RESO or OData.
Image helper: relative path → https://cdn.repliers.io/{path}?class=medium; absolute URL → append class=medium; cap images at 20; lightbox uses class=large. Allow cdn.repliers.io for next/image if using Next.js.
Write normalize(raw, board) → Listing: price from listPrice (strip non-numeric) else price; address from streetNumber streetName streetSuffix streetDirection; city = address.city + ", " + address.state; lat/lng from map (drop 0,0 and non-finite); beds = numBedrooms + numBedroomsPlus; baths = numBathrooms + numBathroomsPlus; sqft and sqftMax from a "min-max" string; type house|condo|townhouse from details.propertyType and class; transactionType lease when type/listingType contains lease or rent; pass through status, lastStatus, soldPrice, soldDate, listDate, daysOnMarket; cooling ← details.airConditioning; pool ← details.swimmingPool; condoFee ← condoFee else HOAFee; annualTaxes ← taxes.annualAmount; lot width/depth; rooms from rooms[] with description.
Attribution: listedBy = office.brokerageName or agents[0].officeName; coListedBy = distinct agents[].brokerage.name and raw.CoListOfficeName/3/4 (when fields include raw), excluding listedBy, joined " / ". Keep only "our" agents (no brokerage name, or matches {BROKERAGE_REGEX}, e.g. /acme realty/i). On brokerage-attribution boards set agent "" and presentedBy "{BROKERAGE_DISPLAY_NAME}" but keep attributedAgents internally.
GET /api/token returns "{unixSeconds}.{hex}" = HMAC-SHA256 of the timestamp with INTERNAL_API_SECRET, TTL 30 minutes. (This is scrape friction, not security — say so in a code comment.)
GET /api/listings and GET /api/listings/[mlsNumber] require Authorization: Bearer <token> or x-internal-secret.
List params: page (≥ 1), pageSize (clamp 10–200, default 50), market (selects boards; never sent to Repliers), class, type sale|lease, status A|U, minPrice, maxPrice, minBeds, maxBeds, minBaths, maxBaths, city, neighborhood, postalCode (≤ 120 chars, reject <>;), repeated propertyType option ids, bounds "swLng,swLat,neLng,neLat" → map=[[[swLng,swLat],[neLng,swLat],[neLng,neLat],[swLng,neLat],[swLng,swLat]]] (then omit city/neighborhood/postalCode). Invalid bounds → 400.
Fan out per board with Promise.allSettled; put a failed board in _meta.error. Dedupe by mlsNumber across boards. Return { listings, page, pageSize, hasMore, totalCount, _meta }.
Detail: try each board with mlsNumber, status=A, fields=*,raw; first non-empty wins; status=U only when the caller passes the VOW check (Prompt 8). 404 if none.
status=U without VOW access → 403 { error: "Sold listing data is available to VOW members only. Please sign in." }. Add lastStatus=Sld when the UI means "sold".
Map UI option ids to per-board Repliers params in one module. {PER_BOARD_MAP} — e.g. a PROPTX-style board uses propertyType: Detached + Link, Semi-Detached, Att/Row/Twnhouse, Condo Apt, Condo Townhouse, Duplex/Multiplex/Triplex/Fourplex; an ITSO-style board uses raw.ITSO_PropertyAttached=Detached / "Semi Detached", propertyType Row/Townhouse, Condo/Apt Unit; a Naples-style board uses lowercase raw.PropertySubType values.
Repeated params are OR. If a selection mixes a raw key and propertyType on the same board, drop the raw key and widen (e.g. add Single Family Residence). Skip any board that cannot satisfy a selected id. Keep a separate, clearly named map for statistics if the board's stats vocabulary differs (e.g. title case).
/homes-for-sale (client): sale/rent toggle, market pills, min/max price (600 ms debounce), beds/baths, multi-select property type, page size 24 (150 in map view). Use a Load more button or numbered pages — and never show a count larger than what can be reached. Write filter state to the URL and read it back on load.
ListingCard: medium photo carousel, CAD price (USD for Florida rows if you split currency), address, city, bd · ba · sqft (range when sqftMax), board chip, Sold badge when lastStatus/status matches sld|sold|u|closed|leased, New when daysOnMarket <= 0 or listed within 24 h, heart. Link to /listings/{slug}.
Map (Mapbox GL, NEXT_PUBLIC_MAPBOX_TOKEN): price-pill markers, popup with address/city/brokerage/photo (escape strings), viewport changes debounced 500 ms become bounds; leaving map view clears bounds.
/listings/[slug] (server): parse MLS, redirect non-canonical slugs, fetch with the server helper (active, then sold only if VOW). Gallery: mobile carousel, desktop mosaic (1 + 4, "+N"), lightbox at class=large with arrows/Escape/scroll lock. Sections: price (sold price + SOLD badge when sold), address, beds/baths/sqft/type, description, listing details (MLS #, board, style, year, garage, parking, fireplaces, basement, heating, cooling, pool, lot, taxes, maintenance fee, listed by, co-listed by, presented by, listed on, DOM), extras/inclusions/exclusions, rooms table m/ft (×3.281), inquiry form. Title "{address}, {city}, {BRAND}", noindex unless status is A, BreadcrumbList JSON-LD. Footer: data courtesy of {BOARDS} via {BROKERAGE}, deemed reliable but not guaranteed, not intended to solicit buyers or sellers already under contract. No similar listings or schools unless backed by real data.
Supabase listing_catalog (mls_number PK, board, market, transaction_type, property_class, property_type, status default 'A', price, street_number, street_name, unit_number, address, city, province, postal_code, address_key, full_address, lat, lng, slug, beds, baths, updated_on, last_seen_at, indexed_at) with pg_trgm GIN indexes on address, full_address, address_key, and catalog_sync_state per board. Public read where status = 'A'; writes via service role only.
GET /api/cron/catalog (CRON_SECRET or INTERNAL_API_SECRET): task=sync every 10 minutes — backfill sortBy=createdOnAsc from a cursor until full, then incremental sortBy=updatedOnDesc stopping at the last updatedOn watermark (don't advance it if the page cap is hit); resultsPerPage=100; 220 ms between pages; on 429 retry 5 times with 1,2,4,8,16 s backoff; boards sequential (shared rate limit); upsert in chunks of 500. task=sweep every 6 hours walks fields=mlsNumber, updates last_seen_at and deletes rows not seen in a full cycle. Never store photos or agent PII.
GET /api/listings/search?q=&limit=8 (q 2–120 chars, limit 1–15): search the catalog and rank exact MLS, MLS prefix, address prefix, address contains, full address contains; fall back to Repliers search= (no searchFields) if the table is missing or returns zero; Cache-Control private, max-age=15, stale-while-revalidate=60. Listing sitemaps from the catalog in chunks of 10,000, active only.
Supabase email auth. Registration requires a VOW terms checkbox linked to /vow-terms (board copyright, personal non-commercial use, any brokerage-only attribution rule, IDX licence text). Signup metadata terms: "true"; trigger handle_new_user() sets profiles.vow_terms_accepted_at = now() when terms = 'true'.
isVowMember() is server-only: cookie session user via auth.getUser() AND profiles.vow_terms_accepted_at not null; any error → false. Enforce it in /api/listings, /api/listings/[mlsNumber] and the server detail page before any status=U Repliers call.
"Show Sold (VOW)" toggle: members send status=U&lastStatus=Sld; others see a modal linking to /login?next=… . Sold detail pages are noindex. saved_listings (user_id, mls, snapshot of address, price, image URL, beds, baths, slug), owner-only RLS; the heart redirects signed-out users to login. Never rely on CSS blur or localStorage to hide sold data.
Market stats call the same /listings URL with statistics= and listings=false (or resultsPerPage=1). EVERY stats query includes type=sale. Sold metrics: status=U&lastStatus=Sld with minSoldDate/maxSoldDate; statistics=avg-soldPrice,med-soldPrice,avg-daysOnMarket,med-daysOnMarket,cnt-closed,pct-aboveBelowList. Monthly history adds grp-mth; new listings use statistics=grp-mth,cnt-new with minListDate. Read statistics.soldPrice.mth["YYYY-MM"].avg/.med, daysOnMarket.mth, closed.mth and new.mth (number or {count}). Cohorts: city, neighborhood, exact beds/baths (minBeds=maxBeds, minBaths=maxBaths). If a neighbourhood cohort has fewer than 24 sales over 12 months, fall back to city and say so. Label any sale-to-list approximation as an approximation.
POST /api/inquiry: modes inquiry | showing (date + time); fields name, email, phone, message. Honeypot field, 3 s minimum / 6 h maximum form age, 15 requests/hour/IP, optional Turnstile. Store inquiries with the service role (kind, mode, mls, listing id, agent ids, showing date/time, region). Resolve recipients server-side from posted agent ids (roster lookup), else office region or nearest office to lat/lng, else a default lead manager from config. Never accept a recipient email from the client. Send via Resend (RESEND_API_KEY). Do not show a success toast unless the request succeeded.
Verify:
1. No request URL contains /Property, $filter or a RESO host; every listing call is api.repliers.io/listings via the server.
2. No REPLIERS key appears in the client bundle; the browser never calls api.repliers.io.
3. /api/listings without a token returns 401; with a token it returns active rows with board source and CDN URLs containing class=medium.
4. status=U without a VOW session returns 403; a member with vow_terms_accepted_at gets rows; those detail pages are noindex; sold rows have lastStatus Sld.
5. Sending page=2 to the server yields Repliers pageNum=2; a page past the end returns an empty list, not an error.
6. Every stats request includes type=sale.
7. Brokerage-attribution boards show presentedBy and an empty public agent.
8. /listings/anything-{MLS} redirects to the canonical slug when active; an unknown MLS slug lands on the "unavailable" state.
9. Catalog sync upserts and stops at the watermark; sweep removes rows that left status=A; typeahead falls back to live search if the table is missing.
10. Inquiry POST writes a row and emails the resolved roster agent; the body cannot set the recipient.
11. Filters survive a reload (URL state) and the visible count never exceeds what can be paged to.
Nothing matches that.
Try a broader word, like “photos”, “sold” or “prompt”.