DIY Rental Platform — Public Marketplace (Astro)
The anonymous, SEO-critical public face of the Malaysian rental marketplace: a new Astro 7 app at rent.gremlin.my that server-renders search and listing pages, reads Go directly for anonymous public data, and ships no island framework at all. A 22-question grilling session on 2026-08-26 settled the domain, the indexing rules, image handling, and the Seeker-to-Tenant handoff into the panel — and uncovered three gaps in slice 1's schema that must be fixed before its public API contract freezes.
Summary
This is the anonymous, SEO-critical public face of the Malaysian rental marketplace — a
new Astro 7 app at rent.gremlin.my that server-renders every page, reads Go
directly for public data, and ships no island framework at all. Slice 1 proves owners will
list and slice 2 proves the two sides will transact; without this app, both are a private tool.
A Seeker — someone with no account — finds a Malaysian rental listing through Google, browses and filters without a page reload, and clicks through to arrange a viewing.
Success criteria
- A listing detail page is indexable and renders a correct card when shared on WhatsApp
- Filtering does not reload the page, and every result set has a shareable URL
- No public page ever renders an owner name, phone, email, or exact street address
- The site still serves a useful page when the API is down
- A URL that was ever indexed or shared keeps resolving — slugs never change (ADR-0057)
- Clicking "Arrange Viewing" lands on the booking form for that listing, even though the Seeker has to create an account on the way
Architecture at a glance
Data flow
browser ──► Cloudflare edge (cached HTML)
└─► Astro SSR on Workers
└─► GET {RENTAL_API_URL}/api/public/rental/listings?…
RENTAL_API_URL = https://api.kokweng.net (origin only)
One module, src/lib/rental-api.ts, exports searchListings(params)
and getListing(slug). They own path constants, query-string building, a fetch
timeout, and response typing. No page calls fetch directly.
App layout
apps/rental/
astro.config.mjs ASTRO_ADAPTER pattern, copied from quiz; no integrations
src/
layouts/Layout.astro ClientRouter + prefetch
pages/
index.astro
property/[slug].astro
sitemap.xml.ts
404.astro
unavailable.astro rendered for the 503 degraded state
components/
FilterForm.astro <form method="GET"> + inline requestSubmit() on change
ListingCard.astro
PhotoGallery.astro CSS scroll-snap + anchors, no JavaScript
lib/
rental-api.ts the only module that calls fetch
areas.ts allowlist of indexable areas
image.ts /cdn-cgi/image URL builder
Pages are / (search form + results), /property/{slug} (listing
detail), /sitemap.xml, and a static /robots.txt. Filters live
entirely in the query string:
?area=&min_rent=&max_rent=&type=&furnishing=&page=
Key decisions
Which domain does the marketplace serve?
rent.gremlin.my — a subdomain of the zone the platform already uses.
The account holds two zones: gremlin.my (quiz) and kokweng.net
(api., auth., panel.). Choosing
kokweng.net means the marketplace and the panel share a registrable domain,
so the "Arrange Viewing" handoff and its post-sign-up return are same-site, with no
cross-domain state to carry.
Deferred: a new dedicated .my domain, which reads as more credible to a
Malaysian Seeker. SEO authority accrues to whichever domain ships first — if the product
earns a dedicated domain later, move it before the site has rankings worth keeping.
Route through Bun, or call Go directly?
Direct SSR-to-Go for anonymous reads, recorded as an exception in ADR-0056.
AGENTS.md requires frontend calls to go through Bun on port 3100 so auth
cookies are forwarded and cross-origin problems are avoided. Neither applies to an
anonymous server-to-server read. Proxying would add a hop and make a public marketing
site depend on Bun's uptime.
Rejected: following the existing rule literally. Recorded as a documented exception rather than a silent bypass, so the next reader finds a decision instead of a violation.
Which island framework?
None at all. Filtering auto-submits from a five-line inline script.
The site needs almost no client state — filters are a GET form, navigation is
ClientRouter, the gallery is CSS scroll-snap. quiz
and plans, the two standalone Astro apps, both ship zero integrations. A
bare form would force the Seeker to press Search after every change, so
FilterForm.astro calls form.requestSubmit() on
change — progressive enhancement, no runtime, no dependency, no hydration.
Rejected: Qwik (platform carries ~10 lines of bundler workarounds and pins
a beta) and Solid (would add a second island framework to this area for no gain). If a
piece ever genuinely needs state the answer is Qwik, because
shared-ui already holds the form controls — adding qwik() is
one config line and changes no page.
Server-rendered + ClientRouter
- Browser calls API
- Never
- Full-page CDN cache
- Yes
- Indexing story
- Strong
- Hydration JS
- None
ClientRouter intercepts link clicks and form submissions, fetches the next page's HTML, and swaps the DOM — preserving scroll and focus. platform already uses it.
Client-side fetch from an island
- Browser calls API
- Yes — needs CORS
- Full-page CDN cache
- Weakened
- Indexing story
- Weaker
- Hydration JS
- On every search page
Costs CORS on the public endpoint, hydration JavaScript everywhere, and a weaker indexing and caching story — the three things this app exists to be good at.
What the repository already decided
Five findings from exploring frontend/astro and slice 1's schema, three of
which correct the slice 1 design.
A new app beside platform needs an ADR
policy
AGENTS.md permits independently deployed products beside the platform app
"only when an ADR records the product and data boundary", with the quiz app as the one
approved exception (ADR-0054). This work starts with ADR-0056, not with a folder.
All frontend API calls are supposed to go through Bun
exception takenBrowser and SSR both call Bun on 3100, which proxies Go on 8080; calling Go directly is marked wrong. Neither reason applies to an anonymous public read.
Related drift worth noting: AGENTS.md claims automation,
bulk, exports, ai, reports,
notifications, and leave are proxied. The gateway's
routes/index.ts actually proxies only mobile/sync and
fieldforce.
Islands here are Qwik, and @astro/core is empty
corrects slice 1
Slice 1's design did not say which island framework applies, and claimed reuse of
@astro/core — frontend/astro/packages/core/ is
empty. Only shared-ui exists, holding two Astro components
and seven Qwik form controls.
Three gaps in slice 1's schema
time-boxedrental_property_photosstores no image dimensions — a zero-CLS page has nothing to setwidth/heightfromareais a free-text column, not an enum or reference table, so owners mint their own spellingsrental_listingshas notitlecolumn, so both the page title and the slug must be composed from structured fields
Astro 6 is a major version behind
version drift
Installed is 6.4.8 across platform, quiz, and
plans; latest is 7.2.7, with @astrojs/cloudflare at 14.2.5 and
@astrojs/node at 11.1.4. Qwik has no stable 2.x at all — latest is
2.0.0-beta.41, and platform has been running
2.0.0-beta.32 all along.
Scope
In scope
- New app
frontend/astro/apps/rental, node adapter for dev and Cloudflare for build, copying theASTRO_ADAPTERpatternquizandplatformalready use - ADR-0056 recording the product boundary and the direct-to-Go exception
- Search page: area, price range, property type, furnishing
- Listing detail at
/property/{slug}, with photos, terms, house rules, and the "Direct from Owner" badge - "Arrange Viewing" linking into the panel's booking entry route from slice 2
- SEO: per-listing titles and descriptions, Open Graph, canonical URLs, JSON-LD,
sitemap.xml,robots.txt - Cache headers so Cloudflare serves repeat traffic, including
stale-if-error - A degraded state when the API is unavailable
- Edge image resizing through
/cdn-cgi/imageso 5MB owner photos do not ship as-is - A curated allowlist of indexable areas, also used to build the filter dropdown
- Cloudflare Web Analytics plus one "Arrange Viewing" click event
Out of scope, by decision
Any authenticated page, Seeker accounts, saved listings and favourites, map view, mortgage calculators, agent listings, hand-written area landing pages, review or rating display, languages beyond English, GA4 or any cookie-based analytics, and any Bun proxying.
Deliberately not handled
- Cache purging
- Suspending a listing leaves its public page warm for up to five minutes. No purge call is built.
- A slug enumeration endpoint
- The sitemap pages through the existing paginated API.
- Upgrading the three existing Astro apps
- They stay on 6 until someone upgrades them deliberately.
- Normalizing
area - The allowlist contains the blast radius without touching slice 1's schema. A reference table is the upgrade path.
- Slug redirects
- Slugs are immutable (ADR-0057), so no history table and no 301 handling exists. That is what makes it safe to have none.
Caching
| Response | Header |
|---|---|
| Listing detail | public, s-maxage=300, stale-while-revalidate=3600, stale-if-error=86400 |
| Search results | public, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400 |
| Degraded or error | no-store |
Search gets the shorter window because filter combinations multiply cache entries and each is cheap to regenerate. Detail pages are the ones worth holding.
SEO and the indexing rule
- Per-listing title and description built from the listing — for example "3-bedroom condo for rent in Petaling Jaya — RM2,000/month" — with the description drawn from the owner's text and truncated.
- Open Graph tags carrying the cover photo, so a WhatsApp share renders a card. In this market that is a real traffic source.
- JSON-LD
RealEstateListingon each detail page. sitemap.xmlgenerated from live listings and cached an hour.robots.txtallowing everything and pointing at the sitemap.
Slice 1's public API is paginated and offers no way to ask for every live slug at once, so the sitemap route pages through until exhausted, with a hard cap. At current volume that is one request. A dedicated slugs endpoint on the Go side is the upgrade if listings ever reach the thousands.
Images
- Resize at the edge
lib/image.tsbuilds/cdn-cgi/image/width=…,format=auto,…URLs over the public R2 photo URL. No build step, no image pipeline, no dependency.- Explicit dimensions
widthandheighton every<img>, taken from the new photo dimension columns, so the browser reserves the right box and CLS stays at zero.srcset- A small fixed set of widths, so a phone does not download a desktop image.
- Lazy loading
loading="lazy"everywhere except the LCP image — the first card's photo on/, and the cover photo on a detail page. Lazy-loading the LCP image delays the exact paint being measured.- Fallback
- If
/cdn-cgi/imageturns out not to be available on the zone (verified in delivery step 1): cap photo dimensions at upload in slice 1 instead, and keep everything else unchanged.
What this design requires from slice 1
| # | Requirement | Why |
|---|---|---|
| 1 | width and height columns on rental_property_photos, populated at upload from the image header, and exposed in the public response |
Zero-CLS rendering. Slice 1 already reads the file at upload to enforce the 5MB and type limits, so the dimensions are in hand at that exact moment |
| 2 | An areas facet on the public search response — the distinct areas across all live listings, independent of the current filters |
The filter dropdown is allowlist ∪ live areas. The search endpoint is paginated, so the areas on page 1 are not the areas in the data. A facet on the existing response costs no extra request and rides the existing cache headers |
| 3 | The public path is /api/public/rental/…, unversioned |
Pinned in ADR-0056, so the two sides cannot drift |
| 4 | Slug rules per ADR-0057: generated only when slug is null, on first transition to live; composed {bedrooms}-bedroom-{property_type}-{area}-{suffix}; always a 4-char suffix; empty slugified parts dropped; suffix-only as the last resort; never fails an approval |
/property/{slug} is what Google indexes and what people paste into WhatsApp |
The Arrange Viewing handoff
This is the one conversion moment in the product, and it is where a Seeker becomes a Tenant. Every Seeker is signed out by definition, so the click always meets a sign-up wall.
This app's side
- The button keeps the label "Arrange Viewing". Alternative considered: "Sign up to arrange viewing" — rejected, it advertises friction on the page whose job is to create interest. The click event is what tells us whether that friction is actually costing conversions.
- It links to
https://panel.gremlin.my/tenant/book/{slug}— the specific listing, not a list.
What slice 2 must add
-
A
/tenant/book/{slug}routeSlice 2 currently has only
/tenant/requests, which is a list — landing an anonymous person there loses the property they came for. -
Slug survives the sign-up round trip
The Seeker must return to that exact route afterwards. Same registrable domain, so this is same-site state, not a cross-domain problem.
-
A "no longer available" state
The route resolves the slug on load and, when it no longer resolves, shows "This property is no longer available" with a link back to
rent.gremlin.my— never a raw 404. This is the visible symptom of the "no cache purge" trade-off; the design accepts the staleness, and this is where it gets handled.
Privacy boundary and error handling
| Case | Behaviour |
|---|---|
| API timeout or unreachable, page cached | Cloudflare serves the stale copy via stale-if-error; the Seeker sees a working site |
| API timeout or unreachable, page never cached | "Temporarily unavailable" page, HTTP 503, no-store |
| Unknown slug | 404 page with a link back to search |
| Listing no longer live | The same 404 path — the API returns only live listings |
Junk filter values such as min_rent=abc | Ignored and treated as unset. Never a 500 |
| Page number past the end | Empty state, not an error |
| No results | Plain empty state suggesting wider filters |
| Seeker clicks "Arrange Viewing" on a listing suspended within the last 5 minutes | Handled on the panel side, not here. This app cannot know, by design: it has no purge |
Measurement
Nothing else in this design tells you whether it worked, and without that you cannot tell a listings-supply problem from a tenant-demand problem.
- Cloudflare Web Analytics — one script tag, free, cookieless, so it raises no PDPA consent obligation and needs no consent banner.
- One custom event on the "Arrange Viewing" click, which is the number that matters: it separates "nobody visits" from "people visit but will not sign up", and it is what decides whether rendering the booking form before requiring an account is ever worth building.
- GA4 is out of scope by decision. It drags in cookie consent obligations this site does not otherwise have.
If custom events turn out not to be available on Cloudflare Web Analytics, the fallback is to record the missing measurement as a known gap rather than to add a heavier analytics stack for one number.
Testing
rental-api.tsunit tests — query building, response parsing, timeout behaviour, and error shape, against a stub server. Follows the existingapps/quiz/src/middleware.test.tsprecedent.- Leak test — render a listing detail and assert no phone, email, owner name, or exact
address_lineappears in the HTML. - Contract-drift test —
rental-api.tsadded to slice 1's public golden fixture client list, asserting client ⊆ Go (ADR-0052 pattern). - Indexing rule unit test — the function that decides
indexvsnoindex, follow, covering: bare/, allowlisted area alone, non-allowlisted area alone, area plus any second filter, andpage=2. This rule is invisible in the rendered page and easy to break silently, which is exactly what makes it worth a test. - One Playwright happy path — land on
/, filter by area, open a listing, and confirm "Arrange Viewing" points atpanel.gremlin.my/tenant/book/{slug}.
Deliberately not: visual regression, Lighthouse CI, a cross-browser matrix.
Delivery order
- Scaffold
apps/rentalAstro 7 + Cloudflare adapter, deployed on
rent.gremlin.my. Doubles as the four-way spike above. - ADR-0056 and ADR-0057
Done — both written before implementation rather than during it.
rental-api.tsand its unit testsBuilt against a stub.
- Search page
Filter form, results, empty state.
- Layout with
ClientRouterand prefetchGives no-reload navigation.
- Listing detail
Photo gallery, leak test.
- SEO
Titles, Open Graph, canonical rules,
noindexon non-allowlisted filters, JSON-LD,robots.txt,sitemap.xml. - Cache headers and the degraded state
- "Arrange Viewing" link into the panel
- Playwright happy path, deploy
Verifications and open questions
All 22 questions this design opened were settled in a grilling session on 2026-08-26, and the resolutions are folded into the sections above. What is left is not a question but a list of things delivery step 1 verifies, each with a fallback already written down.
| Verification | Fallback if it fails |
|---|---|
ClientRouter intercepts GET form submissions in Astro 7 | A small Qwik island calling navigate() |
Cloudflare honours stale-if-error | The 503 page for uncached requests, unchanged |
/cdn-cgi/image transforms are enabled on the zone | Cap photo dimensions at upload in slice 1 |
The rent.gremlin.my custom-domain binding and DNS record provision correctly | — |
| How much friction two Astro majors cause in Turbo and shared types | shared-ui holds only two Astro components, which the consuming app compiles, so exposure looks small |