Konturas.lt
LTEN
← All work

Booking platform · calendar · server-side tracking

Vila Valentino

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
Visit vilavalentino.lt →
Vila Valentino — screenshot

TL;DR — what's different

  • 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-endNext.js App Router · React · SSR
Back-endNode.js · Drizzle ORM · TypeScript · Zod
DBPostgreSQL 16 (tier 2) · GiST EXCLUDE guard
QueuesRedis · BullMQ (email · calendar · maintenance)
Localizationnext-intl (LT/EN, as-needed) · MaxMind GeoLite2
TrackingGA4 · Consent Mode v2 · Meta CAPI · Google Ads OCI · PostHog
SEOSSR · hreflang · sitemap · JSON-LD LodgingBusiness
SecurityCloudflare Turnstile · rate limiting · Sentry
Authbetter-auth (admin)
OpsDocker · Coolify · Hostinger VPS · web + worker
Next.js App Routernext-intl (LT/EN)Drizzle + PostgreSQL 16Redis + BullMQbetter-authMaxMind GeoLite2Meta CAPIGoogle Ads OCIGA4 + Consent Mode v2Cloudflare TurnstileJSON-LD LodgingBusinessDocker · Coolify · Hostinger VPS

Integrations

Geolocation and language routing

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.


Comparison

Vila Valentino vs. the standard

MetricWordPress + booking pluginVila Valentino
Booking commissionOTA 15–20% or a plugin license0% — direct inquiry
Availability syncManual, or separate calendarsAutomatic iCal import + conflict detection
Double-booking protectionPlugin logic onlyPostgres GiST EXCLUDE (DB level)
PricingFixed or manualDeterministic: seasons, packages, extras, frozen quote
LocalizationTranslation pluginnext-intl + geo routing (Cloudflare / MaxMind)
Ad tracking after Safari ITPBrowser pixel onlyServer-side Meta CAPI + Google Ads OCI
Lithuanian SEO visibilityPlugin + one general pageSSR + JSON-LD + intent pages
Customer data ownershipBelongs to the platform / plugin100% in our own Postgres database

See it live — Vila Valentino website


Numbers

Results

Booking channel

No commission

Phone / OTA onlyDirect website

Localization coverage

2 languages

0 languagesLT + EN with geo routing

Search visibility

Search Console, 90 days

OTA profile only25+ indexed pages (LT + EN)

Non-brand queries

Own intent pages

"mergvakaris" (hen party) ~5–6, "bernvakaris" (stag party) ~7

Availability source

One source of truth

Manual calendarManual blocks + Airbnb/Booking iCal

Ad visibility after Safari ITP

Attribution doesn't run blind

Partial (browser only)Full (server-side CAPI + OCI)

Double-booking risk

Overlap impossible

PossiblePostgres 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.