Case study
A property portal for a boutique agency in Thailand
Prototype and full-stack build prepared for Sunway Estates: catalogue, listing pages, enquiry handling, an agent workspace and an administration area — deployed and running on Cloudflare Workers with a serverless database.
- 55
- Listings
- 4
- Agents
- 6
- Enquiries handled
- ฿2.10B
- Portfolio value
The brief
A small agency needs a site that sells
Sunway Estates has been selling premium villas and apartments across Thailand since 2010. Their existing site is an information resource: it presents the portfolio, but it does not let a buyer narrow it down, and it does not give an agent anywhere to work.
The task was a property catalogue that would carry the weight of a sale — a buyer arrives from an advert, needs to filter fifty-five homes down to three, and has to be able to send the shortlist to their partner. An agent then needs to see that enquiry the moment it arrives, together with the property it concerns, without asking a developer.
What follows is the build that resulted, the decisions worth explaining, and the parts a client would care about.
Decisions
Six choices that shaped it
01
State in the URL, not in React
Every catalogue filter is a query parameter. A buyer can send a colleague a link to "villas in Phuket under ฿20M with a pool" and it opens on exactly that view. It also means the back button works, and the client can bookmark a search they will come back to. The filters are the product as much as the property cards are, and a property portal that cannot be shared is a brochure.
02
One server action per form
The enquiry form is used on the home page, the contact page and every listing page. Rather than copy the validation into each page, there is one server action, one zod schema and one place where a lead is stored, rate limited and pushed to Telegram. A change to the enquiry rules is a change in one file.
03
The agent who owns the listing owns the lead
An enquiry is routed to the agent whose listing it came from, and that agent sees it in their cabinet. Agents can only see and edit their own listings. The rules are enforced on the server in every action, not hidden behind a disabled button.
04
Prices as integers
Property prices are stored in baht as whole numbers. A float would introduce rounding error into the one number in the system that must be exactly right. The US dollar figure shown next to it is derived at render time from a static rate, and can be swapped for a live feed.
05
Audit trail from the start
Every administrative action is written to an activity log with the actor, the action and the timestamp. For an agency handling client enquiries it matters to be able to say who changed a price or closed a lead.
06
Degradation over failure
The photograph CDN rate-limits bursts of parallel requests, so a card whose image fails falls back to a local placeholder. The Telegram notification is best-effort: a webhook outage is logged, and the lead is still saved. Neither failure empties the site.
The stack
Boring on purpose
Nothing here is exotic. Every piece is a mainstream choice with a large community, which matters for a client who will have to maintain this after the engagement ends.
Next.js 15
App Router, server components, server actions
React 19
Server components, useActionState for the forms
Cloudflare Workers
OpenNext adapter, no containers
Cloudflare D1
Serverless SQLite, connections by binding
Drizzle ORM
Typed queries, readable schema
Tailwind CSS v4
Design tokens as CSS custom properties
zod
One schema per action, shared with the form
bcrypt + D1 sessions
Revocable, no third-party auth
Playwright
End-to-end checks against the deployed site
Telegram Bot API
Lead notifications
The result
A site an agent can run without a developer
The point of the build is that the day after it goes live, an agent can add a property, correct a price, answer a lead and mark it closed without opening a ticket. The developer is needed for new features, not for the ordinary work of the agency. That was the brief, and it is the thing the client will feel.