PropertyGuru Singapore Rental Search
Purpose
Search propertyguru.com.sg for rooms or whole units (apartments/condos, HDB flats, landed houses) available for rent in Singapore, filtered by location keyword, monthly budget, bedrooms, and unit-vs-room, and return a structured list of listings — title, monthly price, property type, address, nearest MRT, availability, listing agent, and canonical listing URL — plus the total match count. Read-only: never contacts an agent, enquires, or submits anything.
When to Use
- "Find me a room/apartment to rent in {area} under S${budget}/month."
- Budget-scoped rental hunting (room rental vs whole unit) in a specific Singapore town/district.
- Monitoring new rental listings for a saved search (re-run and diff on
id). - Comparing supply/price across towns (read
result_countper query).
Workflow
The entire search is driven by a URL with query params — there is no need to interact with the search form or dropdowns. The results page is a Next.js app that ships the full, structured listing set inside a <script id="__NEXT_DATA__"> JSON blob, so extraction is a JSON parse, not DOM scraping. The site is behind Cloudflare, so a stealth (verified + residential-proxy) session is required.
-
Create a stealth remote session (Cloudflare — a bare session is served the
Just a moment...interstitial):sid=$(browse cloud sessions create --keep-alive --verified --proxies \ | node -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>process.stdout.write(JSON.parse(s).id))") export BROWSE_SESSION="$sid" -
Pick the base path for the kind of rental:
Goal Path All rentals /property-for-rentApartments / condos (whole unit) /apartment-condo-for-rentHDB flats (whole unit) /hdb-for-rentLanded houses (whole unit) /landed-house-for-rentCondo room rental /apartment-condo-for-rent/room-rentalHDB room rental /hdb-for-rent/room-rentalLanded room rental /landed-house-for-rent/room-rentalThe
/room-rentalsuffix setsentireUnitOrRoom=["room"]; the bare path returns whole units. -
Append filter params (camelCase; the site normalizes and echoes them back in
searchParams):freetext=<location>— town/MRT/project keyword, e.g.Tampines,OrchardminPrice=/maxPrice=— monthly rent in SGDbedrooms=<n>— repeat the param for multiple values (bedrooms=2&bedrooms=3)sort=price&order=asc— sort by price ascending (order=descfor high-to-low)page=<n>— 1-based; 20 listings per page
Example:
URL="https://www.propertyguru.com.sg/property-for-rent?freetext=Tampines&maxPrice=2500&sort=price&order=asc" browse open "$URL" --remote browse wait load --remote browse wait timeout 2500 --remote -
Extract from
__NEXT_DATA__— capture the whole page and parse the JSON out-of-band (see the first gotcha: do not usebrowse get texton the script node — it truncates):browse get html body --remote > body.jsonThen in Node: regex out
<script id="__NEXT_DATA__">(…)</script>,JSON.parse, and read:props.pageProps.pageData.resultCount→ total matchesprops.pageProps.pageData.searchParams→ normalized filters (verify yours were applied)props.pageProps.pageData.data.listingsData[]→ 20 listings; each has a.listingDataobject with the fields mapped in Expected Output below.
-
Paginate if needed: re-request with
page=2,3,…up toceil(resultCount / 20). -
Release the session:
browse cloud sessions update "$sid" --status REQUEST_RELEASE
Browser fallback (if __NEXT_DATA__ is absent or extraction fails)
The rendered listing cards carry the same data as visible text. After browse snapshot, each card exposes: title/project, S$ <n> /mo, room-type chip (Master Room / Common Room / whole-unit type), a N pax chip for rooms, property type, built year, availability, nearest-MRT line, and Listed on <date>. The canonical listing URL is the card's anchor href of the form /listing/<slug>-<numericId>. This is slower and loses the numeric id/price_value precision you get from __NEXT_DATA__, so prefer the JSON path.
Site-Specific Gotchas
browse get text "script#__NEXT_DATA__"truncates the JSON. The blob is ~1 MB;get textcuts it off mid-string and the parse fails, which pushes agents into scraping the accessibility tree — where listings come back withid: nulland listing URLs missing their numeric suffix. Always usebrowse get html body(or a targetedbrowse evalreturning only the fields you need) and parse the full__NEXT_DATA__block.- Cloudflare WAF.
--verified --proxiesis required. A bare session lands on theJust a moment...challenge page (title) and__NEXT_DATA__will reflect the challenge, not listings. Confirmed by the pre-run probe (301 →cloudflare,cloudflare-waf). - Prioritized / sponsored listings jump the queue. Even with
sort=price&order=asc, the first fewlistingsDataentries can be out of price order (observed: S$2,950 appearing before S$4,700 and S$3,200). If you need strict ordering, re-sort client-side bylistingData.price.value. ChecklistingData.isPrioritizedto flag promoted entries. - Params are camelCase and get rewritten. Input
maxprice/beds[]is accepted but the canonical URL usesmaxPrice,bedrooms. TrustpageData.searchParamsin the response to confirm what actually applied, not the URL you sent. - Room rentals have
bedrooms: 0. A room listing is identified byentireUnitOrRoom=["room"](from the/room-rentalpath) plus alistingFeatureschip ofMaster Room/Common Roomand aN paxoccupancy chip — not by thebedroomsfield, which is 0 for rooms. property.typeGroupencodes the family:H= HDB,N= non-landed (condo/apartment/walk-up/cluster/exec-condo),L= landed. The bare/apartment-condo-for-rentpath pinspropertyTypeCode=[CONDO,APT,WALK,CLUS,EXCON]andpropertyTypeGroup=N.shortAddress/areaare often empty strings for HDB listings;fullAddress(e.g.Blk 612B Tampines North Drive 1) is the reliable address field.pricePerArea.localeStringValue(S$ 6.00 psf) is present even whenareais blank.- No JSON-LD on search pages (0
application/ld+jsonblocks).__NEXT_DATA__is the only embedded structured source. Individual/listing/...detail pages may carry richer schema, but that's a different skill. - No public unauthenticated JSON search API was found reachable from the search page — the Next.js SSR payload is the data source. Don't burn time hunting a REST endpoint; parse
__NEXT_DATA__. - Dates are relative to crawl time (
Listed on Oct 01, 2026 (3d ago));recency.textcarries both absolute and relative forms.
Expected Output
{
"success": true,
"query": {
"path": "/property-for-rent",
"location": "Tampines",
"min_price": null,
"max_price": 2500,
"bedrooms": null,
"kind": "any",
"sort": "price asc"
},
"result_count": 837,
"page": 1,
"listings": [
{
"id": 22513270,
"title": "Tampines",
"price": "S$ 900 /mo",
"price_value": 900,
"currency": "SGD",
"bedrooms": 0,
"bathrooms": 1,
"property_type": "HDB Flat",
"property_type_group": "H",
"room_type": "Room",
"address": "Blk 612B Tampines North Drive 1",
"price_per_area": "S$ 6.00 psf",
"nearest_mrt": "690 m (8 min) from CR6 Tampines North MRT (U/C)",
"availability": "Ready to Move",
"agent": "Goh Betty",
"agent_license": "R044749A",
"listed": "Listed on Oct 01, 2026 (3d ago)",
"is_verified": true,
"is_prioritized": false,
"url": "https://www.propertyguru.com.sg/listing/hdb-for-rent-tampines-22513270"
}
],
"error_reasoning": null
}
Room-rental outcome shape (note bedrooms: 0, a room_type chip, and a pax occupancy value):
{
"success": true,
"query": { "path": "/apartment-condo-for-rent/room-rental", "location": "Orchard", "max_price": 1800, "kind": "room" },
"result_count": 264,
"listings": [
{
"title": "Orchard Towers",
"price": "S$ 1,400 /mo",
"price_value": 1400,
"bedrooms": 0,
"room_type": "Common Room",
"pax": 6,
"property_type": "Apartment",
"availability": "Available from 15 Oct",
"url": "https://www.propertyguru.com.sg/listing/for-rent-orchard-towers-..."
}
],
"error_reasoning": null
}
Failure shapes:
// Cloudflare block (bare / non-stealth session)
{ "success": false, "result_count": 0, "listings": [], "error_reasoning": "cloudflare_challenge: page title 'Just a moment...', __NEXT_DATA__ not populated with listings" }
// Valid search, zero matches
{ "success": true, "result_count": 0, "listings": [], "error_reasoning": null }