Build a rental search
Connect location search, rental filters, pagination and property details into one coherent experience, with a reusable TypeScript client.
A useful rental search starts with a place and a budget, then helps someone understand the homes that match. This walkthrough connects the relevant endpoints without fetching every detail record up front.
You will build the data flow for a city picker, a paginated results list and a property detail view. You will also see where owner information, similar listings and search statistics fit, and how to handle records that change while a user is browsing.
Before you begin
You need a working API key and a server-side TypeScript environment with Fetch support. If you have not made an authenticated request yet, complete the quickstart first. The examples below run on your backend; your frontend calls your own application.
The workflow
Location search → Choose a city → Filtered rental results
│
├── Next page, with the same filters
├── Statistics for the whole search
└── Open a listing
├── Owner profile, if available
└── Similar listingsResolve locations through the locations endpoint and retain their IDs. Use summary records for result cards. Retrieve a listing's full content when the user opens it, then load related information only where the interface needs it.
Create a small server-side client
Save the following as luntero.ts. It builds query strings, attaches the Bearer header, applies a per-request timeout and preserves the status and request ID when an API call fails.
The types describe the fields used in this walkthrough. They are deliberately smaller than the full OpenAPI schemas; a TypeScript type assertion does not perform runtime response validation.
export type Locale = "en" | "nl" | "de" | "fr" | "es";
export type Query = Record<string, string | number | undefined>;
export type Envelope<T> = {
data: T;
meta: { locale: string; request_id: string };
};
export type Page<T> = Envelope<T[]> & {
pagination: { limit: number; has_more: boolean; next_cursor: string | null };
};
export type Location = {
id: string;
name: string | null;
type: string | null;
};
export type Listing = {
id: string;
title: string;
price_eur: number;
size_m2: number;
rooms: number;
owner_id: string | null;
image_url: string | null;
location: { city: string; street: string; number: string | null } | null;
};
export type ListingDetail = Listing & {
description: string | null;
images: string[];
sources: { url: string; type: string }[];
};
export type Owner = { id: string | null; name: string | null; website_url: string | null };
export type Statistics = {
count: number;
min_price_eur: number | null;
max_price_eur: number | null;
average_price_eur: number | null;
median_price_eur: number | null;
};
export class LunteroError extends Error {
constructor(public status: number, public requestId: string | null) {
super(`Luntero request failed with HTTP ${status}.`);
}
}
export function createLunteroClient(key: string) {
return async function get<T>(path: string, query: Query = {}): Promise<T> {
const url = new URL(path, "https://api.luntero.com");
if (url.origin !== "https://api.luntero.com") {
throw new Error("Only the Luntero API origin is allowed.");
}
for (const [name, value] of Object.entries(query)) {
if (value !== undefined) url.searchParams.set(name, String(value));
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${key}` },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
// Finish reading the response before handing the error to the caller.
await response.arrayBuffer();
throw new LunteroError(response.status, response.headers.get("X-Request-ID"));
}
return (await response.json()) as T;
};
}Use that client from another server-side module. The following setup is shared by the remaining TypeScript examples:
import {
createLunteroClient,
LunteroError,
type Envelope,
type Listing,
type ListingDetail,
type Locale,
type Location,
type Owner,
type Page,
type Query,
type Statistics,
} from "./luntero";
const key = process.env.LUNTERO_API_KEY;
if (!key) throw new Error("Set LUNTERO_API_KEY before starting the server.");
const get = createLunteroClient(key);
const locale: Locale = "en";This client makes one attempt per call. Keep failures visible to the calling layer; use the bounded retry pattern if you want to retry temporary errors. Do not let a public user supply an arbitrary destination URL to this authenticated client.
Resolve a city
Search for the place the user entered and constrain the result type to CITY:
const cities = await get<Page<Location>>("/v1/locations", {
q: "Utrecht",
type: "CITY",
locale,
limit: 10,
});Display cities.data in a picker using the returned localized name, and save the chosen record's id. A query may return no matches or multiple candidates, so do not silently select the first record as though search were an exact lookup.
For a city-only interface, the type filter avoids mixing cities with provinces, boroughs and neighborhoods. If you later add those location types, preserve each record's type and ID instead of guessing from its label.
Search within the selected city
Once the user selects a city, build one filter object and keep it with the result state. Here, selectedCityId is the ID chosen in your city picker.
async function searchCity(selectedCityId: string) {
const filters = {
location_id: selectedCityId,
min_price: 1000,
max_price: 1800,
min_size: 45,
furnishing: "FURNISHED",
locale,
limit: 20,
} satisfies Query;
const firstPage = await get<Page<Listing>>("/v1/listings", filters);
return { filters, firstPage };
}The example asks for furnished homes with a monthly rent between €1,000 and €1,800 and an area of at least 45 m². Those criteria are illustrative; expose controls that make sense for your own product.
Show each result's title, rent, area and available image. Treat rooms as total rooms, not a bedroom count. If the image or location is missing, show an intentional fallback rather than a broken element or the string null.
Result order is stable ID order. Do not label this list “newest first” or “lowest price first.” Sorting only the twenty displayed cards would reorder that page, not the entire matching dataset.
Handle an empty result
If firstPage.data is empty, explain that no published rentals matched the selected filters. Let the user widen the budget, reduce the minimum size or remove the furnishing requirement. Preserve their chosen city so they do not have to start over.
Load the next page
Use the returned cursor only when the API says there is another page. This function is used after searchCity and receives the state returned by that function:
async function loadNextPage(
filters: Query,
previousPage: Page<Listing>,
): Promise<Page<Listing> | null> {
const cursor = previousPage.pagination.next_cursor;
if (!previousPage.pagination.has_more || !cursor) return null;
return get<Page<Listing>>("/v1/listings", { ...filters, cursor });
}Append the new records and replace the stored cursor with the one from the new response. Disable the “Load more” control while the request is pending so a double click does not append the same page twice. De-duplicating displayed cards by listing ID is also useful when your application can receive duplicate UI actions.
When filters or language change, discard the old cursor and request a new first page. When a cursor expires, offer to refresh the search. Do not retry an unchanged invalid cursor indefinitely.
Open a property detail view
Use a listing ID from the result cards and keep the same locale:
async function openListing(listingId: string) {
try {
const result = await get<Envelope<ListingDetail>>(
`/v1/listings/${encodeURIComponent(listingId)}`,
{ locale },
);
return result.data;
} catch (error) {
if (error instanceof LunteroError && error.status === 404) return null;
throw error;
}
}A null return here is your application's unavailable state, not the API's successful response shape. The API returned 404, and this function translates that into a value your interface can handle. Other failures remain errors so they are not mislabeled as missing properties.
On a successful result, you can show the description, gallery and source links. Sanitize content appropriately when rendering it, and keep source attribution visible. A listing returned in search can become inactive before it is opened, so let the user return to the results when the detail is no longer available.
Add owner information and alternatives
Use owner_id only when present. Owner profiles have their own public visibility, so a missing public owner profile should not prevent you from displaying an otherwise available listing.
async function loadOwner(ownerId: string | null) {
if (!ownerId) return null;
try {
const result = await get<Envelope<Owner>>(
`/v1/owners/${encodeURIComponent(ownerId)}`,
{ locale },
);
return result.data;
} catch (error) {
if (error instanceof LunteroError && error.status === 404) return null;
throw error;
}
}
async function loadAlternatives(listingId: string) {
return get<Envelope<Listing[]>>(
`/v1/listings/${encodeURIComponent(listingId)}/similar`,
{ locale, limit: 4 },
);
}Similar listings match the reference city and property type within 25% of its rent, then rank by relative price and size difference. They are useful alternatives, not a guarantee of equivalent amenities or a personalized recommendation. The result is a bounded array without pagination; an empty array simply means there are no matching alternatives to show.
Load these sections when a property is opened or the user requests them, rather than issuing additional calls for every search card. It keeps the first screen quicker to assemble and avoids spending the key's request allowance on details the user may never open.
Show statistics for the search
The statistics endpoint accepts the same substantive listing filters, but not pagination parameters. Use it when you want a count or rent summary across all matching active listings, rather than just the current page.
async function loadSearchStatistics(filters: Query) {
const { limit, cursor, ...searchFilters } = filters;
return get<Envelope<Statistics>>("/v1/listings/stats", searchFilters);
}Statistics are a separate request against a changing dataset. Their count may differ from a list traversed over several pages. Handle null rent aggregates when a search has no usable values, and label the result as a summary of matching advertised rentals.
These statistics are different from the historical market observations. Do not build a trend chart by treating the current search response as a historical time series.
Finish the interface
You now have the requests needed for a complete search flow. Give each visible state an intentional treatment: loading, no matching rentals, more results available, listing unavailable, temporarily unable to load, and a rate-limited request that can be retried later.
Keep the selected filters when a request fails. Record request IDs with unexpected errors. Coordinate calls sharing a key, respect Retry-After, and keep the secret on the backend. For the exact failure handling, return to Errors and rate limits.