A private villa in Ukmergė district, on Lake Kliepšiai. Instead of renting through Booking.com or a plain WordPress page — its own booking system with an availability calendar, geographic localization and server-side conversion tracking.
2024–2026·Build, design, architecture, maintenance·Iterative·Private holiday rental · direct bookings
A custom availability calendar: thinks in nights, not days; a half-open interval lets one guest leave and another arrive on the same day
Deterministic pricing with a frozen quote snapshot — seasons, weekend and weekday nights, packages and extras all calculated to the cent
iCal sync with Airbnb and Booking.com every 10 minutes + conflict detection; a Postgres GiST EXCLUDE constraint blocks any overlap
Geographic routing: a Lithuanian visitor sees LT, a foreigner sees EN (Cloudflare country header + MaxMind GeoLite2), with a year-long cookie for repeat visits
Server-side conversion tracking (Meta CAPI + Google Ads OCI) that survives Safari ITP, with consent management via Consent Mode v2
Situation
The problem
Vila Valentino needed a direct booking channel, independent of commissions. Inquiries came in by phone and through OTA* platforms, which take 15–20% of every stay and keep the guest's data to themselves. A plain WordPress page with a booking plugin doesn't solve that: availability would still need to be entered by hand, and the calendar wouldn't talk to Airbnb or Booking.com — creating a double-booking risk.
Solution
How we got there
We started not with a WordPress theme, but with the data model: availability, pricing and the booking inquiry in one Postgres database. Next.js App Router with LT/EN next-intl localization, a custom availability calendar, deterministic pricing with frozen quote snapshots, iCal* sync with external platforms, and server-side conversion tracking that survives Safari ITP*. There's no checkout screen — it's an inquiry model, where the owner confirms the dates by hand.
Result
What changed
The villa now takes booking inquiries directly, with no commission, and shows up in search for hen-party, stag-party, family-getaway and local keywords in both LT and EN. Availability is merged into one source of truth from manual blocks and the Airbnb/Booking.com calendars, and ad conversions reach Meta and Google even when the browser restricts tracking.
Narrative
How it was built
01 / START
Starting point
Vila Valentino is a private villa in Ukmergė district, in the village of Kliepšiai by Lake Kliepšiai, roughly an hour from Vilnius, Kaunas and Panevėžys. Four bedrooms, up to ten adults, a sauna, a hot tub, a terrace, a private pond. Guests used to book by phone or through OTA platforms. Every reservation through Booking.com or Airbnb meant a commission and a third party sitting between owner and guest. So we didn't start with a WordPress theme — we started with the data model: how to hold availability, price and inquiry in one place so none of the three contradicts the others.
02 / POSITION
Why not Booking.com, why not a WordPress plugin
For a growing rental, both paths share the same weak point. An OTA platform brings traffic, but it takes a cut and keeps the customer data to itself — the owner never sees who inquired, from where, or when. WordPress with a booking plugin looks cheaper, but availability has to be entered by hand, and the plugin's calendar doesn't talk to Airbnb — miss one block and the same weekend gets sold twice. Vila Valentino skipped that stage entirely: all the logic sits in one system — one database, one source of truth for date, price and inquiry.
03 / CALENDAR
An availability calendar that thinks in nights
The booking calendar was built from scratch. The core decision: it counts in nights, not days — a stay is stored as a half-open interval* from check-in to check-out, so on the same calendar day one guest can leave in the morning and another arrive in the afternoon, without the calendar treating that day as booked for both. That's the industry standard (Airbnb, Booking.com), and one that plain plugins often get wrong.
Availability for any given date is assembled from two sources: manual blocks the owner sets on the admin side, and imported iCal events from external platforms. A new reservation can only be accepted for a slot when no active block's interval overlaps it. Check-in day carries a 3 PM Vilnius-time cutoff: after that, the current day is no longer offered as a check-in date — so a guest can't book a slot that's already gone.
04 / PRICING
Deterministic pricing with a frozen quote
Price is calculated by deterministic logic*, not a guess. Every night's rate is derived from season rules and owner-set exceptions: separate weekday and weekend night rates, a first-night surcharge, weekly (7-night) and monthly (30-night) packages, and optional extras — sauna, hot tub in winter. Every amount is stored in euro cents, with no floating-point rounding error.
Once an inquiry is submitted, the calculated quote is frozen as a separate snapshot: even if the season's rate changes later, the price shown to that guest doesn't change. This is an inquiry model — there's no checkout screen; the quote is split into a deposit and a balance, and the owner confirms the dates by hand.
05 / SYNC
iCal sync and conflict detection
The villa isn't listed only on its own site — dates get taken through Airbnb and Booking.com too. To stop the same weekend selling twice, a background worker imports the external platforms' iCal calendars every 10 minutes, reconciles them against existing records, and marks the ones that disappeared. If an import comes back empty while the database still holds active events, the system doesn't age out every record at once — so one broken response can't wipe the whole calendar.
Every sync checks for overlaps: if a manual block and an imported event collide, a conflict watch is logged, with an audit trail. The final double-booking safeguard sits in the database itself — a Postgres GiST EXCLUDE* constraint physically refuses to store two overlapping manual blocks.
06 / GEO
Geographic language routing
A Lithuanian visitor should see the Lithuanian version, a foreign visitor the English one, and neither should have to go looking for it. The root address acts as a routing decision: Cloudflare's country header and MaxMind GeoLite2* determine the visitor's country and pick the LT or EN version. The choice is written to a cookie that lasts a year, so a repeat visit doesn't re-run the geolocation. Search crawlers (Googlebot and others) are always routed to the LT version, matching the hreflang* default. Localization runs on next-intl*'s "as-needed" mode — Lithuanian content carries no locale prefix, English sits behind /en.
07 / SEO
Indexing and intent pages
Instead of one general page, the villa has separate pages by guest intent: a countryside venue for a hen party, a stag party, a family getaway, a company retreat, by the lake, with a hot tub, for Christmas. Each one is rendered server-side (SSR*) with Lithuanian meta tags and JSON-LD LodgingBusiness* markup, so Google sees the content immediately, not an empty shell. Per Search Console data (90 days), the villa is indexed across more than 25 pages in LT and EN and ranks for non-brand keywords — "sodyba mergvakariui" ("countryside venue for a hen party") at position 5–6, "sodyba bernvakariui" ("...for a stag party") at position 7, plus local queries for Ukmergė district. International impressions come not just from Lithuania but from the United Kingdom, Latvia and other countries — the EN version reaches the foreign guest.
08 / INFRA
Infrastructure and background jobs
The villa runs on servers we manage: separate containers for the website (web) and the background worker, a Postgres 16 tier-2 database, Redis* for queues, deployed through Coolify* on a Hostinger VPS. The background worker runs three queues: email (notifying the owner of a new inquiry), calendar sync (iCal import, conflict reconciliation) and maintenance (clearing stale records and rate limits). Cloudflare Turnstile* and request rate-limiting guard the form against spam, and Sentry captures errors. The admin side (better-auth*) lets the owner review inquiries, block dates, and manage season pricing and iCal sources.
Tracking
Tracking — three channels that survive browser restrictions
[ Tracking · Server-side ]
Over the past few years, browsers have cut off a large share of the data ads rely on. At first glance it looks like ads convert worse, when in reality some conversions simply stop being recorded. Vila Valentino's inquiry signal isn't sent from the browser alone: the key events also travel from the server, with consent management and geographic context, so Meta and Google each count the same event exactly once.
Browser analytics
GA4 · Consent Mode v2 · PostHog
GA4* page and event tracking; the primary conversion is a submitted booking inquiry
Consent Mode v2*: analytics and marketing channels only fire once the matching consent is given
PostHog session recordings and funnels — enabled only with analytics consent
Meta
Browser Pixel + server-side CAPI
Browser Pixel and server-side CAPI* send the same event with a shared event ID
Deduplication by event ID — Meta never counts one inquiry twice
Geographic enrichment (country, city); contact data is hashed before it's sent
The event reaches Meta even when Safari ITP restricts the browser
Google Ads
OCI / Enhanced Conversions
Click ID* capture from ad links: gclid, gbraid, wbraid
Server-side conversion delivery with hashed contact data
Consent-gated; keeps working past browser tracking restrictions
Three channels, one event ID. On most sites tracking happens only in the browser and goes quiet once Safari ITP restricts it — here the purchase signal also travels from the server, so ad optimization doesn't run blind.
Tech stack
Architecture
Next.js App Router with server-side rendering and next-intl LT/EN localization. Drizzle ORM* over PostgreSQL 16 (tier-2 database): availability, manual blocks, iCal events, season rules, price exceptions, pricing snapshots and inquiries. Redis + BullMQ* for three queues (email, calendar sync, maintenance). better-auth for admin access. All SEO/geo/tracking capability is inherited through the shared @abyss packages. Deployed via Docker + Coolify on a Hostinger VPS, with separate web and worker containers.
Front-end
Next.js App Router · React · SSR
Back-end
Node.js · Drizzle ORM · TypeScript · Zod
DB
PostgreSQL 16 (tier 2) · GiST EXCLUDE guard
Queues
Redis · BullMQ (email · calendar · maintenance)
Localization
next-intl (LT/EN, as-needed) · MaxMind GeoLite2
Tracking
GA4 · Consent Mode v2 · Meta CAPI · Google Ads OCI · PostHog
SEO
SSR · hreflang · sitemap · JSON-LD LodgingBusiness
Instead of a separate AI layer, the villa relies on precise integrations. Geographic routing via Cloudflare's country header and MaxMind GeoLite2* picks the LT or EN version before the first pixel is painted, a year-long cookie remembers the choice, and search crawlers are routed to the hreflang* default LT version. Localization runs on next-intl's "as-needed" mode. All of this capability — tracking, geolocation, SEO, security — is inherited through the shared @abyss packages, so the same proven logic runs across other projects too.
Challenges
What had to be solved
01
Nights, not days
A reservation has to be stored as a half-open interval [check-in, check-out), so the checkout day stays free for the next guest to arrive. Plain plugins treat a day as simply "booked or free" — so they can't handle a same-day handover, and lose bookings over it.
02
One source of truth for availability
Availability had to be merged from manual blocks and iCal events (Airbnb, Booking.com) into one answer with no duplicates. Different platforms' calendars update at different intervals — the reconciliation has to tolerate that lag.
03
Empty-import protection
If an external calendar comes back empty because of a network error, naive logic would mark every record as gone and free up dates that are actually still booked. It needed a safeguard: an empty response doesn't age out active records all at once.
04
Double-booking protection at the DB level
Application-level checks aren't enough — concurrent writes can slip through. A Postgres GiST EXCLUDE constraint physically blocks the overlap, so two colliding reservations can never land in the database, even in theory.
05
Frozen price quote
A season's rate can change after a guest has already received a quote. The calculated price is stored as a snapshot tied to the inquiry, so later rate changes never touch an amount that's already been quoted.
06
Consent timing map
Browser and server events behave differently depending on Consent Mode v2 state. It needed a precise definition of which event fires, and in what form, before consent is given — without breaking the regulation or losing the conversion.
07
Attribution after Safari ITP
The browser's Meta Pixel and the server-side CAPI have to send the same event, not two. If the event ID or context diverges, Meta ends up optimizing against skewed data. Deduplication through a shared event ID solves this on both channels — Meta and Google.
08
Geo routing before first paint
Language selection has to happen as a server-side routing decision, not a later browser-side correction — otherwise crawlers and ad traffic would see the wrong version. The decision is made from the Cloudflare header before the content is even rendered.
09
Two-way calendar sync
Importing other platforms' dates is only half the job. To stop Airbnb or Booking.com from accepting a booking on dates the villa took directly, the villa's own booked dates are exported back out as an iCal feed. The link is protected by a revocable signature (HMAC) — it can be cut off at any time, and no internal data is exposed through it.
Advantages
Where it beats the standard
01A direct channel, no commission
The inquiry comes straight to the owner, not through an OTA platform charging 15–20%. Guest data stays with the villa, not with a middleman.
02One source of truth for availability
Manual blocks and the Airbnb/Booking.com calendars are merged into one answer, with automatic sync and conflict detection.
03Double-booking protection at the foundation
The overlap is blocked by the database itself, via a GiST EXCLUDE constraint, not just an application check. The same slot physically can't be sold twice.
04A calendar that matches industry standard
The half-open night interval and the 3 PM check-in cutoff behave like Airbnb or Booking.com — without the errors plain plugins make.
05Transparent, deterministic pricing
Seasons, weekday and weekend nights, packages and extras are all calculated to the cent, and the quote is frozen — the price shown to the guest never changes.
06Attribution after Safari ITP
Server-side tracking means Meta and Google still get the inquiry signal even when the browser goes quiet. Ad optimization doesn't run blind.
07Lithuanian SEO and geo routing
Intent pages (hen party, stag party, family) with SSR and JSON-LD; a Lithuanian visitor sees LT, a foreign one EN, automatically by country.
08Data ownership and predictable cost
All the data sits in its own Postgres database, with no percentage taken from any stay. The monthly server bill doesn't depend on the number of bookings.
09Consent-based tracking
Analytics and marketing tags only fire once consent is given (Consent Mode v2), and contact data is only ever sent hashed. Privacy is built into the tracking foundation, not bolted on the side.
Partial (browser only) → Full (server-side CAPI + OCI)
Double-booking risk
Overlap impossible
Possible → Postgres EXCLUDE protection
International reach
Search Console, 90 days
— → LT + UK, Latvia, Australia impressions
The specific implementation recipes — the availability-interval logic, the pricing formula, the iCal reconciliation and conflict-detection sequence, the event ID deduplication scheme — aren't published here. They're presented separately to a serious client.