gora.
Aperwind — Thailand property catalogue

Tech stack

Repository, data layer, daily sync, rendering, SEO layer, media, deployment and technical debt.

~6 min read · 1175 words

TL;DR

Next.js 15 on the App Router, React 19, TypeScript, PostgreSQL 16 in Docker, Node 22 as a single process under systemd, nginx in front. Seven production dependencies: next, react, react-dom, pg, rrweb, rrweb-player, isbot. No CSS framework, no ORM, no i18n library — 1,715 lines of hand-written CSS, hand-written SQL, a hand-written dictionary.

Alongside the site there is a Python catalogue sync pipeline and 397 tests on node:test. 170 commits since 31 August 2026, 31,260 lines of TypeScript.

Stack and architecture

Repository

src/app holds the routes, src/lib 96 domain modules with their tests next to them, src/components 48 files for the site and the admin panel, db the schema, migrations and Python sync, scripts 32 tools, content the data kept in git, docs 18 write-ups of subsystems.

Data layer

22 tables in the public schema and 7 in analytics. Analytics sits in a separate schema on purpose: re-importing content runs drop table cascade, and statistics would have left together with the catalogue.

The key table is unit_ids: our own listing numbers with the date each was first seen and last edited. It is the one table the sync does not rebuild. A number is issued once and cannot be restored from anywhere: lose the table and you lose both lastmod in the sitemap and the link to indexed pages.

There is no ORM; catalogue queries are written by hand and carry an invariant: order by u.sheet, u.sheet_row, u.id is mandatory — duplicate slug suffixes are handed out in that order, and without it the same object would get -2 after one re-import and a bare URL after the next, taking every statistic accumulated on that URL with it.

The daily sync

The morning transfer is a Python service on a timer at 10:00 Bangkok time, in four steps: verify the structure of every source sheet, prepare a plan with a snapshot and a quarantine, apply the plan in one transaction under a table lock, and flush the site cache through a secret-protected HTTP endpoint.

Three things it was rewritten for:

One transaction instead of truncate. The first version wiped the listings table and refilled it; a failure halfway left an empty catalogue.

A stale plan is not applied. The database state is re-checked under the lock, and yesterday's approval does not carry over to today's spreadsheet.

Rollback is a normal operation. The last applied plan can be rolled back, and only while the control state of the database still matches.

Rendering and cache

Listing pages are dynamic — they read query parameters. The home page, selections, guides and project pages run on ISR with hourly revalidation. The storefront settings layer is cached for 30 seconds and flushed on write.

The subtle bug lived here: a read that started before an edit would write its snapshot with a fresh timestamp and cancel the invalidation — for up to 30 seconds the storefront showed the pre-edit state, and ISR could freeze it there. Now invalidation moves a generation counter, and a reader whose generation has diverged re-reads before returning a result.

Routes and SEO

A listing URL is six segments: language, deal type, city, district, property type, slug. URL hygiene lives in middleware: lowercase on the decoded path, 410 for a dead section inherited from the donor site, 301 for empty GET-form fields and ?page=1, 308 for a section without a language prefix.

The sitemap is split into four — pages, listings, objects, projects — for 1,958 URLs, which is 979 pages in two locales. Thin pages with neither photographs nor description stay out: a sitemap entry combined with noindex in the head is a straight contradiction. lastmod is set only on listings whose edit we witnessed.

Structured data covers 23 schema.org types. Indexation thresholds for listing pages live in their own module and are covered by tests: a city needs five properties, everything else three, a listing page without a district is always noindex.

Media

Photographs are not served by Next. Sources are processed by sharp into four widths (400/800/1200/1600) and served by nginx with a year-long immutable cache: 70,076 webp files over 7.6 GB, 22,508 path rows in the database.

The chosen cover and the classification of each frame (photo / render / floor plan) live in git rather than in the database: an edit arrives with its evidence and is undone by a single git revert. Divergence is watched by the data check.

Analytics

Events are append-only and no aggregate counters are stored — everything is derived in SQL, so a redelivered batch cannot corrupt a figure. Idempotency at three levels: the event uuid, a navigation key on the client (which absorbs the double effect run in Strict Mode) and the enquiry uuid.

Engaged time is the sum of intervals between activity marks, each capped at 20 seconds: a tab forgotten for two hours does not become two hours of reading. The intake endpoint always answers 204 — telemetry has no right to break the site — but the order of checks inside it matters: opt-out cookie, the salt for the address hash, rate limit, bot detection, Origin, body size ceilings.

Hosting and deployment

One VPS: 4 cores, 16 GB, Docker with Postgres, Node under systemd, nginx with HTTP/2 and HSTS, Let's Encrypt with auto-renewal. Backups every three days to two external stores.

Deployment is a push to main. Two independent triggers: a GitHub webhook with signature verification and a fallback cron every minute comparing HEAD with origin/main. Both call one script under flock: fetch, reset, install, build, restart, smoke request to the home page. A failed smoke stops the release.

What I would rewrite

A single Next process is the ceiling. A project page costs about 1.4 seconds of CPU. Crawling every sitemap URL at 400 requests per minute flooded the queue and nginx returned 148 responses with 504. There is still no page cache and no queue limit.

The rate limit lives in process memory. With one instance that works. Add a second worker and the limit has to move out, together with the ISR cache and generation flushing.

Part of the content cannot be reproduced from the repository. Texts, the covers held in the database and the videos can only be restored from the three-day dump. Covers and frame classification already moved into git; texts have not.

Listing identity rests on the "project slug + code" key. Fifteen listings have no code at all, and five keys are ambiguous. The real fix is a stable number in the supplier's sheet itself; everything here compensates for someone else's schema.

The data check does not run itself. Eleven checks, from a missing price to a duplicate cover, are run by hand. They belong on a timer next to the sync.

The English guide corpus has drifted from the catalogue. 86 numbers in the English texts lag behind the database. The verification pipeline exists; running it across the English guides is still ahead.