Case study // 06
Gimhani Tours
Marketing site for a local tour guide, with WhatsApp enquiry routing.
- Role
- Engineer
- Year
- 2026
- Category
- Web
- Status
- Live
- Next.js 14
- React 18
- TypeScript
- CSS
- Static export
- Sharp
- SEO
// CONTEXT
The problem
Gimhani Tours is one SLTDA-certified guide and one Nissan Clipper mini van, running personalised trips around Sri Lanka. The customers are foreign travellers who find the business by word of mouth and then talk to it on WhatsApp. A site already existed; this was a rebuild of it, page by page, around a harder question than layout: how a static export with no server, no database and no CMS behind it can still turn a stranger reading in Japanese or German into a message the guide can quote from a phone.
// CONSTRAINTS
What made it hard
- The site is a static export, so nothing exists at request time — no form endpoint, no availability lookup, no per-locale routing, not even a directory listing to build a gallery from.
- The audience is not local. The site is read in eight languages and the guest reviews come from six countries, so no visible string could be left sitting inside a component.
- The material was enormous: 4000×6000 destination photographs, 1200×1600 guest PNGs and a 71 MB 10-bit HEVC hero video, adding up to a 211 MB deploy aimed at people on hotel wifi.
- A one-van business cannot be asked to operate software. Anything that needs a reply has to land where the guide already works, in the format they already answer in.
// DECISIONS
What I chose, and what I didn't
- 01
Keep the booking an enquiry, and make its last step compose a WhatsApp message rather than submit anything.
WhyA static export cannot hold a date, take a deposit or know what is already sold, and a one-van operator selling bespoke itineraries has no fixed price to publish anyway. What the five steps — trip, dates, group, contact, review — are actually for is the quality of the message that arrives: one free-text box produces “hi how much?”, while a dated, sized, routed enquiry can be quoted without a round trip. Nothing is transmitted from the page; the visitor presses send in WhatsApp themselves, which is exactly what the privacy policy describes.
Instead ofA contact form posting to a third-party form service — another account to maintain, another inbox to forget, and replies landing somewhere the business does not actually work.
- 02
Serve all eight languages from one dotted-key dictionary that falls back to English, and render English until the client has mounted.
WhyA static export rules out Next’s locale routing, and duplicating fourteen routes into eight copies is a maintenance trap. A single 4,129-line dictionary, looked up through a path that degrades from the active locale to English to the key itself, means a missing translation reads as English rather than as a blank. Rendering English until mounted keeps the server output and the first client render identical, so the language picker does not cost a hydration mismatch on every string — and the document’s lang attribute follows the choice, with zh emitted as zh-Hans, so a screen reader does not read Japanese copy under English pronunciation rules.
Instead ofRouted locale segments, which a static export cannot produce without exporting every page eight times, or a permanently English lang attribute that misreports the page to assistive tech and translation tools.
- 03
Measure the image problem before writing the conversion script, and move the masters out of the export rather than deleting them.
WhyThe obvious instruction — convert everything to WebP — is wrong where it matters most: a 3.40 MB 4000×6000 JPEG re-encodes to 5.82 MB as WebP at q92, because WebP only beats a well-packed JPEG at display sizes. The real waste sat in two different places. The guest photographs were 2.85 MB PNGs, which is the wrong container for a photograph and converts at unchanged pixel dimensions for roughly an 87% saving; the destination photographs were simply four times larger than any screen can show them, so they are capped at 2560px on the long edge. That pass took the deploy from 211 MB to 122 MB. Originals move to _source-images/ at the same relative path, so they stay in the repository as masters and can be re-processed at any setting — they just stop being copied into the static export.
Instead ofA blanket convert-and-resize across the whole folder, which would have made the largest files bigger while destroying the only copy of the originals.
- 04
Re-encode the hero to a single H.264 High 8-bit 1080p file, and read the video element’s readyState on mount instead of trusting the canplay event.
WhyThe old desktop hero was HEVC Main 10: Firefox cannot decode it at all and Chrome only where the hardware obliges, so it was not a heavy hero, it was a silently broken one — a large share of desktop visitors saw the poster or black. One H.264 file with yuv420p, faststart and a keyframe every two seconds plays everywhere, and the muted element’s audio track went with it as dead weight. The readyState check fixes the same class of bug on the client: the markup is server-rendered with preload set to auto, so on a warm cache the element reaches canplay before hydration attaches the handler, the event fires into nothing, and a fully loaded video sits at zero opacity indefinitely.
Instead ofKeeping a 4K file for desktop and a smaller one for mobile, which doubles the encoding work, chooses by breakpoint rather than by capability, and still leaves Firefox with nothing to play.
- 05
Build the scroll reveals on one shared IntersectionObserver that adds a CSS class, not on a motion library’s in-view helper.
WhyThe gallery puts hundreds of elements on a single page. whileInView keeps a JavaScript animation alive per element and re-observes on every re-render; a one-shot observer runs once and then costs nothing, and one observer instance covers the page instead of one per element. The off switch lives in CSS: every variant resolves to visible-and-still under prefers-reduced-motion, so nothing has to be cancelled mid-flight in JavaScript.
Instead ofA motion-library call at every reveal site, which is shorter to write and turns the gallery into the slowest page on the site.
- 06
Audit every page at 375, 768, 1024 and 1440, and land the corrections in a stylesheet loaded last.
WhyGuessing at breakpoints is what produced the defects the audit found: uppercase eyebrow and stat labels set between 9.6px and 11.5px, below the point where letterspaced capitals stay readable and with nothing smaller left to shrink to on a phone, and ten controls under the 44×44 target WCAG 2.5.5 asks for — mostly icon-only buttons that lose their label on mobile. Loading the corrections after the twelve feature stylesheets means a measured fix wins without a specificity war, and the tap-target padding is scoped to coarse pointers so a mouse user does not inherit thumb-sized buttons.
Instead ofEditing the values in place across twelve stylesheets, where each fix competes with the rule it corrects and the reason for the number is lost.
// OUTCOME
What came of it
- 14 routes, every one readable in eight languages from a single dictionary, exported as static HTML with a sitemap that ranks the booking path above the policy pages.
- A five-step wizard that hands the guide a dated, sized, routed enquiry inside WhatsApp, without the site transmitting anything itself.
- 198 photographs — 109 from tours with guests, 89 across 16 destinations — served as WebP behind a native dialog lightbox, which takes its focus trap, inert background and Esc-to-close from the platform rather than from hand-written JavaScript.
- A deploy cut from 211 MB to 122 MB by the image pass alone, and a hero that now plays in Firefox and on machines without HEVC decode, where the previous file showed a still or a black rectangle.
- Accessibility handled where it is usually skipped: a pause control on the autoplaying hero, reduced-motion off switches in every stylesheet, endonyms in the language picker, and a lang attribute that follows the chosen language.
- Guest reviews left in the words their authors wrote: the review text is identical across all eight locale blocks, so switching language translates the interface around a testimonial without ever re-voicing it.
// REFLECTION
What I'd do differently
The hero video is still 48.5 MB — 35 seconds of 1080p at CRF 20, about 10.9 Mbps, for footage that plays muted behind a dark scrim and a headline. I set that quality target to protect a cinematic brief that was really about the footage, not about a background loop nobody inspects frame by frame. CRF 26, or a ten-second cut, would have taken most of the weight out with no visible difference, on a site whose own audience is largely on hotel wifi; it is the one place where the measure-first discipline that shaped the image script never reached the video. The second thing I would change is structural: fourteen routes of copy in eight languages with no CMS means every wording change is a code change, and the English fallback that keeps a missing key readable is exactly what would let one locale drift out of date without anyone noticing.
