Buildings and the registry
Condo and townhome sites usually have a page per building. Sonor can match every MLS listing to its building, so each building page shows what's for sale there with one call. Your site tells Sonor which buildings it has by publishing a small registry, and Sonor does the matching.
import { getBuildingListings, getBuildingSummaries } from '@sonordev/re-site-kit/server'
import { buildingRegistry } from '@sonordev/re-site-kit'How it fits together
- Your site serves a registry at a URL of its choosing, built with
buildingRegistry(). - You set that URL as the Building registry URL in Sonor (Real Estate, then Buildings).
- Sonor pulls the registry every hour, and whenever you press Refresh now there, then matches your project's listings to those buildings.
- Your building pages call
getBuildingListings(slug), and every listing carrieslisting.buildingso a listing page can link back to its building.
Publish the registry
// app/api/sonor/buildings/route.ts
import { buildingRegistry } from '@sonordev/re-site-kit'
import { loadBuildings } from '@/lib/buildings'
export const revalidate = 300
export async function GET() {
const rows = await loadBuildings()
return Response.json(
buildingRegistry(
rows.map((b) => ({
id: b.id,
slug: b.slug,
name: b.name,
aliases: b.mlsNames, // other names the MLS uses for it
addresses: b.addresses, // "100-118 Main St" is a range
city: b.city,
state: b.state,
zip: b.zip,
latitude: b.lat,
longitude: b.lng,
url: `https://example.com/condos/${b.slug}`,
})),
),
)
}loadBuildings() stands for wherever your building pages already get their content: a CMS, a JSON file, a database. The URL must be https and on the project's own domain; Sonor won't pull a registry from anywhere else.
buildingRegistry
buildingRegistry(entries: BuildingRegistryEntry[]): {
version: 1
buildings: BuildingRegistryEntry[]
}It's a pure function that tidies your rows so you can map them straight in:
- Slugs are trimmed and lowercased, and names are trimmed.
- An entry without a slug or a name is skipped.
- When two entries share a slug, the first one wins.
- Aliases and addresses are trimmed, and blanks and repeats are dropped. An alias that's the same as the name is dropped too.
city,stateandzipare trimmed, and empty values becomenull.latitudeandlongitudeare kept only when they're finite numbers.- Every optional field comes out as
nullwhen you leave it out.
Registry entries
Only slug and name are required. Every address and alias you add is another way a listing finds its building.
| Field | Type | What it's for |
|---|---|---|
slug | string | Your building's slug. It's what getBuildingListings(slug) and searchListings({ building }) take. Required. |
name | string | The building's name. Required. |
id | string | null | Your own id for it, such as a database row id. It comes back as external_id in building summaries. |
aliases | Array<string | null | undefined> | null | Other names the MLS uses for it: the subdivision or complex names agents type, in every spelling you've seen. |
addresses | Array<string | null | undefined> | null | Street addresses, one per entrance or street number. 100-118 Main St is a range, and a street name with no number (Harbor Way) means the whole street. |
city, state, zip | string | null | Where it is. |
latitude, longitude | number | null | Its location, for matching by proximity. |
radius_m | number | null | How close, in meters, a condo or townhouse listing must be to count as this building when matching by proximity. Default 40. |
url | string | null | The absolute URL of the building's page on your site. It comes back as listing.building.url and in building summaries. |
The route returns JSON like this:
{
"version": 1,
"buildings": [
{
"id": "b-17",
"slug": "harbor-point",
"name": "Harbor Point",
"aliases": ["Harbor Pointe Condominiums"],
"addresses": ["100-118 Main St"],
"city": "Springfield",
"state": "IL",
"zip": "62701",
"latitude": 39.7817,
"longitude": -89.6501,
"radius_m": null,
"url": "https://example.com/condos/harbor-point"
}
]
}How a listing is matched
Sonor tries these rules in order, and the first one that names exactly one building wins:
- A decision someone made in the dashboard for that street address (assigning it to a building, or excluding it).
- The street address, against your registry's
addresses. - The MLS subdivision name, which must equal your building's
nameor one of itsaliases. Part of a longer name never counts. - For condos and townhouses, distance from your building's
latitudeandlongitude, withinradius_m.
When a rule finds more than one building, Sonor doesn't guess. The listing goes to the review queue in the dashboard, and one click there settles every unit at that address for good. Adding addresses and aliases to the registry is how you need fewer clicks.
A building page
// app/condos/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getBuildingListings } from '@sonordev/re-site-kit/server'
import { ListingGrid, summarizeListings } from '@sonordev/re-site-kit'
import { loadBuilding } from '@/lib/buildings'
const usd = (n: number | null) =>
n == null
? null
: new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(n)
export default async function CondoPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const building = await loadBuilding(slug) // your own content
if (!building) notFound()
const { listings } = await getBuildingListings(slug, { limit: 24, sort: 'price_asc' })
const stats = summarizeListings(listings)
return (
<main>
<h1>{building.name}</h1>
{stats.count > 0 ? (
<dl className="market-snapshot">
<div>
<dt>For sale</dt>
<dd>{stats.count}</dd>
</div>
{stats.medianPrice != null ? (
<div>
<dt>Median price</dt>
<dd>{usd(stats.medianPrice)}</dd>
</div>
) : null}
{stats.medianPricePerSqft != null ? (
<div>
<dt>Median $/sqft</dt>
<dd>{usd(stats.medianPricePerSqft)}</dd>
</div>
) : null}
{stats.medianMonthlyHoa != null ? (
<div>
<dt>Typical HOA</dt>
<dd>{usd(stats.medianMonthlyHoa)}/mo</dd>
</div>
) : null}
</dl>
) : null}
<h2>For sale at {building.name}</h2>
<ListingGrid
listings={listings}
hrefFor={(listing) => (listing.slug ? `/listings/${listing.slug}` : null)}
emptyState={<p>Nothing's for sale here right now.</p>}
/>
</main>
)
}summarizeListings is covered in Market snapshot.
getBuildingListings
getBuildingListings(
slug: string,
options?: Omit<SearchListingsOptions, 'building'>,
fetchOptions?: { revalidate?: number },
): Promise<ListingSearchResult>The listings Sonor matched to one building, by its registry slug. It's searchListings with building set, so it takes the same filters, sort, paging and cache window (60 seconds), and returns the same { listings, pagination }. An unknown slug returns an empty page. See Search and filters for every option.
A building's listings come from Sonor's matches in both serve modes.
A building index
getBuildingSummaries(
options?: { site?: string },
fetchOptions?: { revalidate?: number },
): Promise<BuildingSummary[]>Every building Sonor knows for the site, with how many of its listings are for sale and their price range. Buildings with nothing for sale are included with a count of 0. It's cached for 5 minutes and returns [] on any failure.
| Field | Type | What it is |
|---|---|---|
slug | string | The registry slug. |
name | string | The building's name. |
url | string | null | The building's page, from your registry. |
external_id | string | null | Your own id from the registry. |
active_count | number | Active listings matched to it. |
price_min, price_max | number | null | The price range of those listings. |
// app/condos/page.tsx
import { getBuildingSummaries } from '@sonordev/re-site-kit/server'
const usd = (n: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
export default async function CondosPage() {
const buildings = await getBuildingSummaries()
return (
<main>
<h1>Condo buildings</h1>
<ul>
{buildings.map((b) => (
<li key={b.slug}>
<a href={`/condos/${b.slug}`}>{b.name}</a>
{b.active_count > 0 ? (
<span>
{' '}
{b.active_count} for sale
{b.price_min != null && b.price_max != null
? `, ${usd(b.price_min)} to ${usd(b.price_max)}`
: ''}
</span>
) : (
<span> Nothing for sale right now</span>
)}
</li>
))}
</ul>
</main>
)
}Linking a listing to its building
When Sonor has matched a listing, it carries listing.building:
interface ListingBuildingRef {
slug: string
name: string
url: string | null // the building's page, from your registry
}So a listing page can link back:
{listing.building ? (
<p>
In <a href={listing.building.url ?? `/condos/${listing.building.slug}`}>{listing.building.name}</a>
</p>
) : null}listing.building_name is a separate field that comes with the listing data. It isn't tied to your registry, so use listing.building for links.