Case study — Griha Prabesh

Every plot has a shape, a road, and a direction it faces.

A bilingual property platform for a Kathmandu agency that verifies land titles against government records before it lists anything. Land, houses and apartments for sale or rent — searched in ropani and aana, dated in Bikram Sambat, and served fast to a buyer arriving from one Facebook link on a mid-range phone.

Timeline12 daysFirst commit to live, Aug 2026
LocalesEN · NE870 strings each, parity-tested
Surface23Pages — 11 public, 11 admin
TeamOne149 commits, one author
ClientAgencyBaluwatar, Kathmandu
01 — The problem

Nepali land doesn't fit a template built for square feet and Gregorian dates.

Off-the-shelf listing platforms assume one unit system, one calendar and one language. Kathmandu buyers ask how many aana, how wide the road is and which way the plot faces. Terai buyers ask in kattha. Non-resident Nepalis ask in English on a Viber call.

The agency's edge was verification — title checks and physical site visits — but its listings lived in Facebook posts. It needed a site that carried that trust, ranked for every district it worked in, and could be run by two or three staff without a developer.

02 — Role & stack

Solo build,
design handoff in

Seven prototype screens and a design system came in; the data model, both locales, the admin, the deployment and every guard script came out — in under two weeks.

Public siteSearch with URL-held filters and a clustered map, statically generated listing pages, location landing pages, saved shortlist.
Listing wizardMulti-step admin form with autosave, drag-to-reorder photos, map picker for coordinates, SEO and publish steps.
Inquiry inboxEvery listing and contact form feeds one pipeline — NEW through CLOSED_WON / LOST — with notes, assignment and email alerts.
Back officeRoles (ADMIN / EDITOR / AGENT), locations tree, amenities, team profiles, blog editor, site settings with photo watermarking.
Frontend

Next.js 15 · React 19 · Tailwind 4 · shadcn · next-intl

Data

PostgreSQL · Prisma 7 · 17 models · tsvector search

Maps & media

MapLibre GL + OSM tiles · sharp · local-disk storage

Quality

Vitest · 40 test files · Lighthouse + JS-budget harness

Infra

Next standalone · Node on Nepal-hosted Webuzo · Resend

03 — What I built

A storefront for land,
and a desk to run it

Fitment for land

Filter by type, price, area, location, road width and facing direction — every filter lives in the URL and round-trips.

Per-type detail pages

Land, house and apartment each get their own spec layout, gallery, map, deep links to WhatsApp, Viber and phone, and a print sheet.

Nepali units & dates

Ropani-aana-paisa-daam and bigha-kattha-dhur conversion, lakh/crore digit grouping, a Bikram Sambat calendar module.

Location SEO

District and municipality landing pages that exist only while they have an active listing, plus sitemap, canonicals and JSON-LD.

Accountless visitors

Cookie-keyed shortlist, saved searches and view tracking, hydrated client-side so pages stay static.

Bilingual everything

English and Nepali across the public site and the admin, with a test that fails if the two message files drift.

04 — Deep dive

Ropani, aana, paisa, daam

Nepal measures hill land in ropani and Terai land in bigha, prices it in lakh and crore, and dates it in a calendar roughly 57 years ahead of the one your server runs on. None of that can be an afterthought in a property database.

  • Land stored canonically in square metres; built-up area separately in square feet.
  • Hill units (ropani) and Terai units (bigha) derived for display, each with its own slider ceiling.
  • Prices derived per type — a house price is never divided by its plot area.
  • Dates stored in UTC, displayed in Bikram Sambat at UTC+05:45.
1 ropani16 aana≈ 508.72 m²
1 aana4 paisa≈ 31.80 m²
1 bigha20 kattha≈ 6,772.63 m²
05 — Static, defended

Fast on 3G,
honest to Google

Most visitors open one listing from a shared link on a phone and never see the homepage. That page has to be prerendered, correctly indexed and light. Three separate bugs proved that the framework's defaults would quietly break each of those, so each now has a guard in the build.

From URL to guarded build
01Slug/properties/[slug] resolves a location first, then a listing — one namespace, three collision pairs guarded.
02RenderListing and location pages are prerendered at build; visitor state never touches the server render.
03AssertA script reads the prerender manifest and fails the build if a page silently drops to dynamic.
04StatusA second script hits the production build and checks real HTTP codes — drafts must 404, not 200.
05BudgetPer-route first-load JS budgets at measured +15%, with stale-build detection.
06AuditA Lighthouse runner records the LCP element per route so regressions name their culprit.

The baseline audit measured LCP between 3.4 and 4.6 seconds on throttled mobile before the fixes. The commit history keeps the corrections too — a claimed cause that turned out untested was deleted rather than left standing.

06 — Engineering decisions

Bugs the green
checks didn't see

01
The soft-404 that build, lint and tests all missedCorrectness

A loading boundary on the properties segment streamed a 200 header before notFound() ran, so drafts and unknown slugs returned 200 to search engines. Moved search into its own route group and added a status-code check against the production build.

Why — A listing site that indexes its own drafts is lying to Google. Only a real HTTP check catches this class of bug.

02
One cookies() call cost 19 static pagesPerformance

A single cookie read in the render tree quietly demoted 19 of 53 location pages to dynamic rendering. Every check was green. Visitor state was pulled out to the client and a prerender-manifest assertion now guards the build.

Why — Static is a property you have to defend, not a setting you turn on.

03
Canonical square metres, derived everything elseDomain

Storing area in the unit the seller typed would have meant converting in every query and every filter. One canonical column, with hill and Terai unit families derived at the edge, keeps search honest across regions.

Why — A Kathmandu buyer thinks in aana; a Jhapa buyer thinks in kattha. The database shouldn't care.

04
Locations and listings share one slug spaceRouting

A district page and a listing live at the same URL depth so that a Facebook link to either reads clean. A cached resolver tries locations first, then listings, and a registry rejects any slug that would collide.

Why — Most visitors arrive from a single shared link on a phone. The URL is the product's first impression.

05
MapLibre's worker chunk breaks under webpackMaps

Tiles rendered but markers never appeared: the worker path 404'd. A postinstall script copies the worker into public and the map component points at it explicitly.

Why — A property map with no pins is worse than no map. The fix had to run on every install, not live in a README.

07 — Scale

Small on purpose. Every table earns its place.

17 models · 6 migrationsProvinces, districts, municipalities, properties, images, landmarks, inquiries, agents, saved searches and views.
2 locales · 870 stringsFull English and Nepali coverage, with a parity test so neither file falls behind.
7 design prototypesLanding, search, detail, listing form, admin, secondary pages and the design system, all implemented to the handoff.
3 build-time guardsPrerender assertion, status-code check and JS budgets — each born from a bug that slipped past the usual gates.

Built for the buyer on the other end of the link.