Search and filters
Everything a listings index needs: searchListings fetches a filtered, sorted page of listings, ListingFilters is the search form, and ListingPagination links between pages. All three render on the server, and the form works with JavaScript off.
import { searchListings } from '@sonordev/re-site-kit/server'
import { ListingFilters, ListingGrid, ListingPagination } from '@sonordev/re-site-kit'The Quickstart shows them wired together on one page.
searchListings
searchListings(
options?: SearchListingsOptions,
fetchOptions?: { revalidate?: number },
): Promise<ListingSearchResult>It calls Sonor's public listings endpoint through site-kit's server fetcher, with your SONOR_API_KEY. Sonor works out the project from the key, so you never pass a project id.
const { listings, pagination } = await searchListings({
city: 'Springfield',
minPrice: 250000,
bedsMin: 3,
sort: 'price_asc',
page: 1,
limit: 12,
})It returns { listings, pagination }:
| Field | Type | What it is |
|---|---|---|
listings | RealEstateListing[] | The page of listings. See Types for every field. |
pagination.page | number | The page you got. |
pagination.page_size | number | Listings per page. |
pagination.total | number | null | Total matches, when the backend can count them. |
pagination.total_pages | number | null | Total pages, when the total is known. |
It never throws. If the request fails, or SONOR_API_KEY is missing, you get an empty result (listings: [], page: 1, page_size: 0, total: 0, total_pages: 0) and a warning in the server log, so a render never crashes. If the API answers without pagination, the kit fills it in with your page (or 1), the number of listings returned, and null totals.
Options
SearchListingsOptions uses camelCase names. The kit maps each one onto the API's query parameter (the snake_case name you'll see in the URL when ListingFilters submits). Empty strings, null and undefined are left out of the request.
| Option | Query parameter | Type | What it does |
|---|---|---|---|
q | q | string | The search box. See The search box. |
city | city | string | City name. Exact match, case-insensitive. |
zip | zip | string | ZIP code. |
neighborhood | neighborhood | string | Neighborhood name. |
minPrice | min_price | number | Lowest list price. |
maxPrice | max_price | number | Highest list price. |
bedsMin | beds_min | number | Minimum bedrooms. |
bathsMin | baths_min | number | Minimum bathrooms. |
propertyType | property_type | PropertyType | single_family, condo, townhouse, multi_family, land, commercial or other. |
status | status | ListingStatus | active, pending, sold, expired or withdrawn. Leave it out for active listings. |
type | type | ListingType | sale, rent or lease. |
building | building | string | A building's registry slug: only listings Sonor matched to it. See Buildings. |
sort | sort | see Sort | Result order. |
page | page | number | Page number, from 1. |
limit | limit | number | Listings per page. See Page size. |
site | site | string | Multi-site projects only: the sub-site host whose listings you want. |
The search box
q searches the way people type. It works in both of Sonor's serve modes:
- An MLS number on its own (
123456,MLS 123456or#123456) finds that listing. A bare five-digit number is also tried as a ZIP. - Anything else is words, and every word has to appear somewhere in the address, city, ZIP or neighborhood. "12 Oak St, Springfield" finds "12 Oak Street", because Sonor matches common street abbreviations either way, and filler like "homes for sale in" is ignored.
- In db serve mode, descriptions are searched too.
Since every word must match, a search for "3 bedroom pool" usually finds nothing. Point people at the bedroom filter for that and keep the box for places.
Sort
| Value | Order |
|---|---|
updated | Most recently updated first. This is what you get when sort is left out. |
newest | Newest listings first. |
price_asc | Price, low to high. |
price_desc | Price, high to low. |
Page size
Leave limit out and you get 12 listings per page. In live serve mode a page holds at most 25, whatever you ask for. pagination.page_size always tells you what you actually got.
Caching
searchListings is wrapped in React's cache() and its fetch is cached by Next for 60 seconds. Pass a different window as the second argument:
const result = await searchListings({ city: 'Springfield' }, { revalidate: 300 })See Caching and freshness for every fetcher's default.
ListingFilters
A plain <form method="get"> whose field names are the API's query parameters. Submitting it reloads the page with the filters in the URL, your page reads searchParams, and the values go straight back into searchListings. No client JavaScript is involved.
<ListingFilters
basePath="/listings"
values={options}
cities={['Springfield', 'Riverside', 'Fairview']}
showKeyword
/>| Prop | Type | Default | What it does |
|---|---|---|---|
basePath | string | the current URL | The form's action, usually your listings page. |
values | Partial<SearchListingsOptions> | {} | Current values, so the inputs stay filled in. Uses the camelCase option names (minPrice, not min_price). |
cities | string[] | none | When set, City is a <select> of these, with "All cities" first. Otherwise it's a text input. |
showKeyword | boolean | false | Show the search box (q). |
keywordLabel | string | "Search" | Label for the search box. |
keywordPlaceholder | string | "Address, MLS #, city or neighborhood" | Placeholder for the search box. |
submitLabel | string | "Search" | The submit button's text. |
className | string | none | Added to the form next to re-listing-filters. |
The form renders these fields, in this order:
| Label | Field name | Input |
|---|---|---|
Search (with showKeyword) | q | Search input |
| City | city | Text input, or a select when cities is set |
| Min price | min_price | Number input, steps of 1,000 |
| Max price | max_price | Number input, steps of 1,000 |
| Beds | beds_min | Any, 1+ to 5+ |
| Baths | baths_min | Any, 1+ to 3+ |
| Sort | sort | Recently updated (selected when values.sort is empty), Newest, Price: low to high, Price: high to low |
The sort select always submits a value, so after the first search the URL carries sort=updated even if nobody touched it. That's the same order as leaving sort out.
Filters it doesn't have
ZIP, neighborhood, property type, status and building aren't in ListingFilters. If you need them, write your own GET form with the same field names from the options table; searchListings doesn't care which form sent the parameters. Reuse the re-listing-filters classes if you want it to match.
ListingPagination
Previous and next links that keep the current filters and swap only the page parameter. They're ordinary <a> tags with rel="prev" and rel="next", so crawlers can follow them.
<ListingPagination
pagination={pagination}
listingCount={listings.length}
basePath="/listings"
searchParams={query}
/>| Prop | Type | What it does |
|---|---|---|
pagination | ListingPaginationInfo | The pagination object searchListings returned. Required. |
basePath | string | The page the links point at, e.g. /listings. Required. |
searchParams | Record<string, string | number | undefined | null> | The current query to carry through. Empty values and any existing page are dropped. |
listingCount | number | How many listings this page holds, listings.length. Used only when the total is unknown; see below. |
className | string | Added next to re-listing-pagination. |
Page 1 links without a page parameter, so the first page has one URL. The status line reads "Page 2 of 9 (104 listings)", leaving out whichever numbers the API didn't send. Links that can't go anywhere render as a <span aria-disabled="true"> with the re-listing-pagination__disabled class.
Next.js gives you searchParams values as string | string[] | undefined, and this prop takes single values, so flatten them first (the Quickstart's readListingSearch returns a flat query for exactly this).
When the total is unknown. Some live-mode responses can't count matches, so total_pages is null. Pass listingCount and the component goes by the page itself: a full page (as many listings as pagination.page_size) gets a Next link, and a shorter one is treated as the last page. That works on page 1 too. Without listingCount, an unknown total shows a Next link from page 2 on and nothing at all on page 1, so pass it.
getListings
The simple list from before search existed. It's still exported for older code; use searchListings for anything with filters or paging.
getListings(options?: GetListingsOptions): Promise<RealEstateListing[]>| Option | Type | What it does |
|---|---|---|
site | string | Multi-site projects: the sub-site host. |
status | ListingStatus | Defaults to active on the server. |
type | ListingType | sale, rent or lease. |
limit | number | How many listings. |
offset | number | How many to skip. |
It returns a bare array (empty on any failure) and is cached for 60 seconds; there's no revalidate override.
Caching and freshness
Every fetcher in @sonordev/re-site-kit/server is wrapped in React's cache() (one request per render for the same arguments) and sets a Next fetch cache window:
| Fetcher | Default window | Override |
|---|---|---|
searchListings, getBuildingListings | 60 seconds | fetchOptions.revalidate |
getListing | 60 seconds | fetchOptions.revalidate |
getListings | 60 seconds | none |
getHotListings | 5 minutes | none |
getBuildingSummaries | 5 minutes | fetchOptions.revalidate |
getListingParams | 5 minutes | none |
Your site code is the same in both of Sonor's serve modes. In db mode Sonor serves listings it has synced from the feed. In live mode Sonor answers straight from the MLS through a short cache, so a 60-second window keeps pages within a minute or two of the MLS while most visitors still get a cached page. Either way, the MLS credential stays in Sonor; the site only ever holds SONOR_API_KEY.