DEV Community

Isabel Smith
Isabel Smith

Posted on

How We Built a Direct-Booking Tourism Stack That Outperforms OTAs (WooCommerce + Next.js + AI)

Most tourism businesses live and die by OTA commissions. Booking.com takes 15-25%. Viator takes 20-30%. GetYourGuide takes 25-30%. For a small adventure tour operator selling glacier trekking excursions in Patagonia, that's the difference between growth and barely surviving.

Two years ago, we decided to go 100% direct — no OTA listings, no commission-based platforms, just our own stack. This is the technical breakdown of what we built, what broke, and what actually moves the needle.

The Architecture

The core system runs on WordPress + WooCommerce with a specialized booking plugin called WooTours. Here's the high-level stack:

┌─────────────────────────────────────┐

│         calafate.tours              │

│  (WordPress/WooCommerce + WooTours) │

│         Booking Engine              │

├─────────────────────────────────────┤

│    /blog → Next.js on Vercel        │

│    (Reverse-proxied via Cloudflare) │

├─────────────────────────────────────┤

│    Darwin AI Assistant (Gemini API) │

│    Embedded widget on all pages     │

├─────────────────────────────────────┤

│    WhatsApp Bot (OpenClaw)          │

│    Lead capture + booking assist    │

├─────────────────────────────────────┤

│    GTM + GA4 + Clarity              │

│    Analytics layer                  │

└─────────────────────────────────────┘

Let me walk through each layer and the real-world problems we solved.

WooCommerce as a Booking Engine: Harder Than You'd Think

WooCommerce wasn't designed for tour bookings. It was designed for selling products. WooTours bridges that gap, but the integration introduces some gnarly edge cases.

The Date Picker Bug That Cost Us Thousands

WooTours uses pickadate.js for its booking calendar. We run WP Rocket for caching and performance, which includes JS defer/delay features. The problem: WP Rocket's delay mechanism was breaking the date picker initialization on mobile.

The symptom was subtle — the booking widget would appear to load, but tapping a date did nothing. Users would tap, nothing would happen, and they'd bounce. Our add-to-cart to purchase conversion rate was sitting at ~4-5% with 87% cart abandonment.

The fix was surgical: exclude pickadate.js and WooTours' core scripts from WP Rocket's delay/defer lists.

// functions.php - Exclude WooTours scripts from WP Rocket delay

add_filter('rocket_delay_js_exclusions', function($exclusions) {

    $exclusions[] = 'pickadate';

    $exclusions[] = 'wootours';

    return $exclusions;

});

add_filter('rocket_defer_inline_exclusions', function($exclusions) {

    $exclusions[] = 'pickadate';

    return $exclusions;

});

Variable Product Price Inversion

WooCommerce displays variations in a way that sometimes inverts the price order — showing the most expensive option first instead of the base price. For tour products with multiple group sizes, this is a conversion killer. A user sees "$450" instead of "$89" and bounces.

We solved this with an AJAX interceptor in functions.php that re-sorts variation data before it hits the frontend. The key lesson: never use browser console overrides when you have server-side hooks doing the same job. We learned this the hard way when conflicting logic produced phantom pricing errors that took days to debug.

The Next.js Blog: SEO Power, Architectural Pain

Our blog runs on Next.js deployed to Vercel, reverse-proxied through Cloudflare Workers so it appears at calafate.tours/blog. This gives us the SEO benefit of subdirectory content while running a modern React app.

Why not just use WordPress for the blog?

Two reasons:

  1. Performance: Next.js with static generation produces pages that load in <1s. Our WordPress theme (custom, called "test-ct") is heavily modified and loads WooCommerce assets on every page. Blog posts don't need cart functionality.
  2. Content quality: The Next.js blog gives us full MDX support, custom components for comparison tables, and interactive elements that would require shortcode soup in WordPress.

The Host Header Problem

Cloudflare Workers proxy requests from /blog/* to the Vercel deployment. But Vercel needs the correct Host header to route to the right project. If the Worker forwards the original host (calafate.tours), Vercel doesn't know which project to serve.

// Cloudflare Worker - simplified

addEventListener('fetch', event => {

  const url = new URL(event.request.url);

  if (url.pathname.startsWith('/blog')) {

    const vercelUrl = new URL(event.request.url);

    vercelUrl.hostname = 'your-project.vercel.app';

    const modifiedRequest = new Request(vercelUrl, {

      headers: new Headers({

        ...Object.fromEntries(event.request.headers),

        'Host': 'your-project.vercel.app',

        'X-Forwarded-Host': 'calafate.tours'

      }),

      method: event.request.method,

    });

    event.respondWith(fetch(modifiedRequest));

  }

});

The Sitemap Gap

Here's one we caught late: Yoast SEO generates the WordPress sitemap, but it has no knowledge of the Next.js blog posts. Those 20+ blog articles had zero sitemap inclusion, meaning Ahrefs showed UR 0.0 for the entire /blog path.

The fix was generating a separate sitemap-blog.xml from Next.js and referencing it in the WordPress sitemap_index.xml via a filter:

add_filter('wpseo_sitemap_index', function($sitemap_index) {

    $blog_sitemap = '<sitemap>' .

        '<loc>https://calafate.tours/blog/sitemap.xml</loc>' .

        '<lastmod>' . date('c') . '</lastmod>' .

        '</sitemap>';

    return $sitemap_index . $blog_sitemap;

});

AI Assistant: Gemini API as a Sales Agent

We're building an AI virtual assistant called Darwin (named after the Beagle Channel, not the evolutionary theorist — though both apply). It's powered by the Gemini API and will be embedded as a chat widget across our three destination sites.

The system prompt includes tour catalog with real-time pricing, age restrictions per tour (e.g., Big Ice is 18-50, Minitrekking is 8-65), seasonal information, and multi-language support in Spanish, English, and Portuguese.

const systemPrompt = `You are Darwin, a travel assistant for 

adventure tours in Patagonia, Argentina. You help visitors at

${currentSite} choose and book excursions.

CRITICAL RULES:

- Always verify age restrictions before recommending tours

- Never guarantee weather conditions

- For bookings, generate a WhatsApp deep link with pre-filled message

- Respond in the same language the user writes in

`;

The interesting architectural decision: Darwin doesn't process bookings directly. Instead, it generates WhatsApp deep links with pre-filled messages, routing warm leads to our human sales team. Why? Because WhatsApp historically accounts for 52-66% of our revenue and has a 25-35% higher average ticket than web bookings. The AI's job isn't to replace the sales conversation — it's to qualify and warm up the lead before handing it off.

WhatsApp Automation: The Revenue Channel Nobody Talks About

This is the part most dev-focused tourism articles ignore. In Latin American markets, WhatsApp isn't just a messaging app — it's the primary commerce platform.

We run OpenClaw, a locally-hosted WhatsApp bot gateway running on a MacBook Pro. It handles automated greeting and language detection, FAQ responses using a knowledge base, lead scoring based on message intent, and handoff to human agents for booking completion.

The tech stack is Node.js with the whatsapp-web.js library, connected to our CRM via webhooks.

User sends WhatsApp message

        ↓

OpenClaw receives via whatsapp-web.js

        ↓

Intent classification (keyword + pattern matching)

        ↓

  ┌─────────┴─────────┐

  │ FAQ/Info           │ Booking Intent    │

  │ Auto-respond       │ Score lead        │

  │ from KB            │ Notify agent      │

  └────────────────────┴───────────────────┘

Analytics: The Double-Fire Bug

Our GA4 setup had a purchase event firing twice per transaction due to a race condition between the WooCommerce thank_you page and a GTM trigger. This inflated reported revenue by ~36% — a number we only caught by reconciling GA4 data against actual WooCommerce orders.

If you're running WooCommerce + GTM + GA4, always:

  1. Use dataLayer.push with a transaction ID for deduplication
  2. Implement deduplication logic in GTM
  3. Reconcile GA4 revenue against your actual payment processor monthly

We also discovered that a Complianz consent mode misconfiguration was routing a huge chunk of traffic to the "Unassigned" channel in GA4. The fix was adding a consent initialization tag in GTM that fires before any measurement tags.

Results

After 18 months of building this stack:

  • Organic search now outperforms paid search in revenue for the first time
  • ChatGPT referral traffic converts at 10.6% vs 2.4% for standard organic (we didn't expect this)
  • Zero OTA commissions — every dollar goes direct
  • Domain Rating grew from ~8 to 13 and climbing toward our target of 18

The full system runs on a $395/year hosting budget (shared hosting + Cloudflare free tier + Vercel hobby plan). The most expensive component is the Gemini API, and even that's negligible at our current volume.

What I'd Do Differently

  1. Start with the WhatsApp channel first. We built the website, then discovered WhatsApp was the real revenue driver. In Latin American tourism, build the messaging automation before perfecting the web checkout.
  2. Don't use a subdomain for translated content. We initially ran English content on en.calafate.tours, then had to migrate everything to calafate.tours/en/ via TranslatePress. The redirect migration generated 48 redirects and weeks of GSC headaches.
  3. Solve the mobile booking UX before anything else. That date picker bug was live for months before we caught it. Mobile is 70%+ of our traffic. A broken mobile checkout is an invisible revenue leak.

If you're building for tourism or any service-based business in emerging markets, the playbook is: own your distribution, automate your highest-converting channel (probably messaging, not web), and treat your booking funnel like a product, not a page.

The live site is here if you want to poke around the implementation.

Top comments (0)