中文版

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.

  • Predecessor Slice 1 — Listings and Owner Onboarding; Slice 2 — Viewings, Messaging, and Reminders
  • ADRs 0052 · 0054 · 0055
  • New ADRs 0056 · 0057
  • Status Approved

View source markdown ↗ generated by claude-opus-5 · diagrams mermaid

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.

  • Domain rent.gremlin.mypanel.gremlin.my
  • Stack Astro 7 + Cloudflare adapter, zero integrations
  • Audience Seekers — signed out, arriving from Google or WhatsApp
  • Covers Slice 1 step 8, slice 2 step 9 — no OpenSpec change
  • New ADRs 0056 (product boundary), 0057 (immutable slugs)
  • Hard dependency Four contract items must land before slice 1's fixture freezes
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

Request path and the handoff. The browser never calls the API — only the Worker does. The dotted edge is the one conversion moment, and it crosses into the panel
Request path and the handoff. The browser never calls the API — only the Worker does. The dotted edge is the one conversion moment, and it crosses into the panel.

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.

Selected

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.

Rejected

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 taken

Browser 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/corefrontend/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-boxed
  • rental_property_photos stores no image dimensions — a zero-CLS page has nothing to set width/height from
  • area is a free-text column, not an enum or reference table, so owners mint their own spellings
  • rental_listings has no title column, 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 the ASTRO_ADAPTER pattern quiz and platform already 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/image so 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

ResponseHeader
Listing detailpublic, s-maxage=300, stale-while-revalidate=3600, stale-if-error=86400
Search resultspublic, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400
Degraded or errorno-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 RealEstateListing on each detail page.
  • sitemap.xml generated from live listings and cached an hour.
  • robots.txt allowing 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.ts builds /cdn-cgi/image/width=…,format=auto,… URLs over the public R2 photo URL. No build step, no image pipeline, no dependency.
Explicit dimensions
width and height on 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/image turns 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

#RequirementWhy
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.

The handoff, including the stale-cache branch. The slug must survive the sign-up round trip; losing it drops the Seeker at an empty list having forgotten the property
The handoff, including the stale-cache branch. The slug must survive the sign-up round trip; losing it drops the Seeker at an empty list having forgotten the property.

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

  1. A /tenant/book/{slug} route

    Slice 2 currently has only /tenant/requests, which is a list — landing an anonymous person there loses the property they came for.

  2. 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.

  3. 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

CaseBehaviour
API timeout or unreachable, page cachedCloudflare 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 slug404 page with a link back to search
Listing no longer liveThe same 404 path — the API returns only live listings
Junk filter values such as min_rent=abcIgnored and treated as unset. Never a 500
Page number past the endEmpty state, not an error
No resultsPlain empty state suggesting wider filters
Seeker clicks "Arrange Viewing" on a listing suspended within the last 5 minutesHandled 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.ts unit tests — query building, response parsing, timeout behaviour, and error shape, against a stub server. Follows the existing apps/quiz/src/middleware.test.ts precedent.
  • Leak test — render a listing detail and assert no phone, email, owner name, or exact address_line appears in the HTML.
  • Contract-drift testrental-api.ts added to slice 1's public golden fixture client list, asserting client ⊆ Go (ADR-0052 pattern).
  • Indexing rule unit test — the function that decides index vs noindex, follow, covering: bare /, allowlisted area alone, non-allowlisted area alone, area plus any second filter, and page=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 at panel.gremlin.my/tenant/book/{slug}.

Deliberately not: visual regression, Lighthouse CI, a cross-browser matrix.

Delivery order

  1. Scaffold apps/rental

    Astro 7 + Cloudflare adapter, deployed on rent.gremlin.my. Doubles as the four-way spike above.

  2. ADR-0056 and ADR-0057

    Done — both written before implementation rather than during it.

  3. rental-api.ts and its unit tests

    Built against a stub.

  4. Search page

    Filter form, results, empty state.

  5. Layout with ClientRouter and prefetch

    Gives no-reload navigation.

  6. Listing detail

    Photo gallery, leak test.

  7. SEO

    Titles, Open Graph, canonical rules, noindex on non-allowlisted filters, JSON-LD, robots.txt, sitemap.xml.

  8. Cache headers and the degraded state
  9. "Arrange Viewing" link into the panel
  10. 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.

VerificationFallback if it fails
ClientRouter intercepts GET form submissions in Astro 7A small Qwik island calling navigate()
Cloudflare honours stale-if-errorThe 503 page for uncached requests, unchanged
/cdn-cgi/image transforms are enabled on the zoneCap 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 typesshared-ui holds only two Astro components, which the consuming app compiles, so exposure looks small