How to build a short-term rental direct booking site
with Claude, Smoobu & Stripe — in 10+ languages
Airbnb's guest-side service fee adds 14–16% on top of your nightly rate. On a €700 weekly booking, that's €100 disappearing before a guest even confirms. A direct booking site cuts platforms out of that transaction — and with Claude as your build partner, it's a realistic weekend project, not a multi-month engineering commitment.
My own Altbau flat near Boxhagener Platz in Friedrichshain runs on exactly this setup — guests book at a lower price than on Airbnb, same apartment, same hosts. This guide covers the full stack: booking engine, payment flow, multilingual SEO, analytics, an automated Claude agent loop that catches and fixes issues overnight, security hardening, and German legal compliance. Every section includes working code.
The system, drawn as a loop
The key insight is that this isn't a website you build and forget. It's an iterative system — every layer feeds the next, and each iteration makes the whole thing more reliable.
A guest books → GA4 and PostHog fire → a nightly smoke check runs → if something breaks, Telegram alerts → a ticket appears in the backlog → Claude picks it up → a fix deploys → the loop runs again. The core booking flow ships in a weekend. The monitoring, agent loop, and legal layers compound over the weeks that follow — that's by design.
1 Foundation: GitHub + Vercel
Set up the deployment pipeline before anything else. Every subsequent layer ships through it.
Create the repo and project
npx create-next-app@latest your-booking-site --typescript --tailwind --app cd your-booking-site git init && git remote add origin git@github.com:yourorg/your-booking-site.git git push -u origin main
Connect Vercel
- Go to vercel.com → New Project → Import from GitHub
- Select the repo — Next.js is autodetected, no config needed
- Every push to
mainnow auto-deploys to production
No CI files, no build scripts, no server to manage. Vercel handles it.
Environment variables
Set these in Vercel Dashboard → Settings → Environment Variables before writing API code. Mirror locally in .env.local (gitignored — never commit secrets).
SMOOBU_API_KEY=...
SMOOBU_APARTMENT_ID=...
STRIPE_SECRET_KEY=sk_live_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
NEXT_PUBLIC_POSTHOG_KEY=phc_...
TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHAT_ID=...
CRON_SECRET=... # random string, verify in cron handler Scaffold with Claude
Once the repo exists, brief Claude on the full system in a single session:
Claude will scaffold the architecture, flag the non-obvious traps (webhook ordering, the timezone bug), and propose the folder structure before you write a line of business logic.
2 Smoobu: availability, pricing, reservations
Smoobu is the channel manager that aggregates bookings from Airbnb, Booking.com, and your direct site. Get your API key at Settings → External Integrations → API.
Checking availability and pricing
const BASE = "https://login.smoobu.com/api"; const headers = { "Api-Key": process.env.SMOOBU_API_KEY!, "Content-Type": "application/json", }; export async function getAvailability(apartmentId: string, start: string, end: string) { const url = `${BASE}/rates?apartments[]=${apartmentId}&start_date=${start}&end_date=${end}`; const res = await fetch(url, { headers, next: { revalidate: 300 } }); const json = await res.json(); return json.data[apartmentId] as Record<string, { price: number; available: 0 | 1; min_length_of_stay: number; }>; }
available: 0 means blocked. Build a Set<string> of blocked date keys and disable those dates in the calendar. The response is cached for 5 minutes (revalidate: 300) — enough freshness without hammering the API.
The calendar pulls live availability from Smoobu's rates API. Greyed-out dates are blocked — either by existing bookings or minimum-stay rules.
The timezone pitfall — read before writing any date code
Smoobu returns dates as strings: "2026-07-01". If these pass through JavaScript's new Date() and .toISOString(), you get one day early — JS parses date-only strings as UTC midnight, but your guests and calendar are in local time (Berlin is UTC+2).
new Date("2026-07-01").toISOString().split("T")[0]
→ returns "2026-06-30" in UTC+2. One day early. Blocks the wrong dates.
function toLocalDateKey(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; }
Apply this in both your server API routes and client-side hooks. Missing it in one place produces phantom availability bugs that only appear for guests in certain timezones.
Creating a reservation
export async function createReservation(p: { apartmentId: string; checkIn: string; checkOut: string; firstName: string; lastName: string; email: string; adults: number; price: number; }) { return fetch(`${BASE}/reservations`, { method: "POST", headers, body: JSON.stringify({ apartmentId: Number(p.apartmentId), channelId: 5726551, // "Website" channel — shows as its own source in Smoobu arrival: p.checkIn, departure: p.checkOut, firstname: p.firstName, lastname: p.lastName, email: p.email, adults: p.adults, price: p.price, }), }); }
Discount codes — the API that doesn't work
Smoobu has a coupon feature in their UI. The API endpoints to validate codes do not work — multiple documented and undocumented endpoints return 404s or {"success":false}. The workaround: validate server-side using an env var.
DISCOUNT_CODES=SUMMER10:10,DIRECTGUEST:5 # CODE:percent pairs Parse case-insensitively, compute the discount from live Smoobu rates. Forward the code string to Smoobu when creating the reservation for their records, but rely on nothing from their API for validation.
The price breakdown updates live — toggle dog, enter a code, and the total recalculates instantly. Discount codes are validated server-side, never against Smoobu's broken coupon API.
3 Stripe: the webhook-first pattern
The naive flow — create Smoobu reservation first, then take payment — produces ghost reservations when guests abandon mid-checkout. Those dates stay blocked for days. The correct pattern: nothing in Smoobu until money has moved.
How it flows
→ Server creates Stripe PaymentIntent (booking metadata in payload)
→ Guest pays via PaymentElement — inline, no redirect
→ Stripe calls
/api/webhook: payment_intent.succeeded→ Webhook creates Smoobu reservation + sends confirmation emails
→ Telegram alert fires: "✅ Booking confirmed"
Stripe's PaymentElement renders all available methods automatically — card, Google Pay, Link, Bancontact, EPS. No per-method configuration, no extra fees.
PaymentIntent creation
totalPrice from live Smoobu rates on the server. If you accept a price from the request body, an attacker can book any stay for €0.01.
export async function POST(req: Request) { const { checkIn, checkOut, guests, firstName, lastName, email, apartmentId } = await req.json(); // Compute price server-side — recheck on every request const rateData = await getAvailability(apartmentId, checkIn, checkOut); const totalPrice = computePrice(rateData, checkIn, checkOut, Number(guests)); const paymentIntent = await stripe.paymentIntents.create({ amount: Math.round(totalPrice * 100), currency: "eur", metadata: { checkIn, checkOut, guests: String(guests), firstName, lastName, email, apartmentId }, }); return Response.json({ clientSecret: paymentIntent.client_secret }); }
Webhook handler
export async function POST(req: Request) { const body = await req.text(); // raw text — NOT req.json() — required for sig verification const sig = req.headers.get("stripe-signature")!; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!); } catch { return new Response("Signature verification failed", { status: 400 }); } if (event.type === "payment_intent.succeeded") { const pi = event.data.object as Stripe.PaymentIntent; const { checkIn, checkOut, firstName, lastName, email, guests, apartmentId } = pi.metadata; await createReservation({ apartmentId, checkIn, checkOut, firstName, lastName, email, adults: Number(guests), price: pi.amount / 100 }); // fire-and-forget — never await in a webhook handler void sendGuestConfirmation({ checkIn, checkOut, firstName, email, price: pi.amount / 100 }); void sendHostNotification({ checkIn, checkOut, firstName, email }); void sendAlert({ type: "info", message: `Booking confirmed: ${firstName}, ${checkIn}–${checkOut}` }); } return new Response("ok", { status: 200 }); }
Local webhook testing
brew install stripe/stripe-cli/stripe && stripe login
stripe listen --forward-to localhost:3000/api/webhook
# Prints whsec_... — use this as STRIPE_WEBHOOK_SECRET locally 4 10+ languages
Supporting multiple languages isn't about being polite to non-English speakers. It's how your SEO reaches guests who search in German, French, Japanese, or Chinese — and why the same property can appear at the top of searches in multiple markets.


The same page served to a Chinese-speaking vs German-speaking visitor — different headline, different meta tags, different URL slug, different search market. Same codebase.
App Router [locale] routing
app/ [locale]/ ← en, de, fr, es, it, nl, pl, pt, tr, ja, ko, zh layout.tsx page.tsx book/page.tsx page.tsx ← redirect to /en
export const locales = ["en","de","fr","es","it","nl","pl","pt","tr","ja","ko","zh"] as const; export type Locale = typeof locales[number];
Typed translation files
TypeScript enforces that every language covers every key. Add a string to en.ts without updating de.ts and the build fails — you find out at compile time, not when a guest sees a blank field.
export type Translations = { meta: { title: string; description: string }; hero: { heading: string; noFee: string; bookNow: string }; booking: { checkIn: string; checkOut: string; guests: string }; // every user-facing string belongs here }
Use Claude to generate the initial translations for each locale. Review the languages you speak, run DeepL for a second pass on the rest. One full locale takes about 20 minutes with Claude doing the first draft.
hreflang — the bit that makes SEO work
export async function generateMetadata({ params }: { params: { locale: Locale } }) { const t = translations[params.locale]; return { alternates: { languages: { ...Object.fromEntries(locales.map(l => [l, `https://yourdomain.com/${l}`])), "x-default": "https://yourdomain.com/en", }, }, title: t.meta.title, description: t.meta.description, }; }
Without hreflang, all organic traffic collapses to /en/ regardless of the visitor's language. With it, Google serves the right locale to the right market automatically — the Chinese screenshot above can rank on Baidu, the German one on Google.de.
/de/ferienwohnung-berlin, not /de/apartment-berlin. Use the vocabulary guests actually type in each market. Ask Claude to generate locale-appropriate slugs with keyword rationale for each one.
5 Analytics: three layers with different jobs
Google Analytics 4 — the booking funnel
Three events cover the full conversion funnel. Together they answer "where are guests dropping off?"
| Event | Fires when | Key params |
|---|---|---|
begin_checkout | Guest selects dates and continues | check_in, check_out, nights |
add_payment_info | Guest submits their details | guests, locale |
purchase | Booking confirmation shown | transaction_id, value, currency |
analytics_storage and ad_storage to "denied" before any script loads. Update to "granted" in the cookie banner's accept handler. This must run before the GA4 script tag — use strategy="beforeInteractive".
<Script id="consent-default" strategy="beforeInteractive">{` window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('consent','default',{analytics_storage:'denied',ad_storage:'denied'}); `}</Script>
PostHog — session recording and funnel drop-off
PostHog shows you where guests abandon the booking flow — which step, which device, which locale. The funnel report surfaces conversion problems that GA4's aggregated view won't catch.
PostHog funnel report — illustrative numbers. The details step drop-off (−40%) is a typical finding: too many required fields, or a confusing form layout on mobile.
GDPR note: never call posthog.init() on page load. Even opt_out_capturing_by_default fires a /flags/ request before consent. Use lazy init — call posthog.init() only inside the consent-granted handler, never on mount.
PostHog + Claude: close the loop automatically
PostHog pairs unusually well with Claude Code because its event data is straightforwardly readable and pasteable. After a week of real traffic, drop the funnel CSV into a Claude session — "step 2 loses 40% of guests on mobile, here's the data by locale and device" — and ask what to test first. Claude reads the numbers, identifies the highest-leverage change, and can draft the A/B variant copy in the same session.
HogQL on demand. PostHog's query language (HogQL) is close enough to standard SQL that Claude writes it fluently from a plain-language spec: "give me booking completions grouped by locale and device type, last 90 days." Paste the query into PostHog Explore, paste the result back, iterate. The whole analysis loop takes minutes.
The agent loop hook. The nightly cron job can query PostHog's Events API for the week's booking conversion rate. If it drops more than 15% from the 4-week rolling average, a structured ticket is automatically written to the backlog — locale breakdown, device split, affected step — with the data attached. Claude picks it up in the next scheduled run and proposes hypotheses without a human ever noticing the dip first. PostHog is the sensor; Claude is the responder.
Telegram — real-time operational alerts
The most immediately useful monitoring layer. A message arrives in under 5 seconds when a booking confirms, an API fails, or a webhook misfires. No dashboard to check.
export async function sendAlert(p: { type: "info" | "warn" | "error"; message: string }) { const emoji = { info: "✅", warn: "⚠️", error: "🔴" }[p.type]; await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chat_id: process.env.TELEGRAM_CHAT_ID, text: `${emoji} *your-booking-site*\n${p.message}`, parse_mode: "Markdown", }), }); }
Always call as void sendAlert(...) — never await it inside a webhook handler. A Telegram outage should never cause your webhook to return non-200 and trigger Stripe retries.
| Trigger | Alert type | Why it matters |
|---|---|---|
| Booking confirmed | info ✅ | Immediate confirmation without opening Smoobu |
| Smoobu reservation failure | warn ⚠️ | Double-booking risk — needs immediate attention |
| Availability API 5xx | error 🔴 | Calendar may be showing wrong dates to guests |
| Webhook signature failure | error 🔴 | Misconfigured secret or probing attempt |
Beyond instant alerts, you can extend the Telegram bot to deliver a weekly PostHog digest — funnel numbers, button click counts, and the exact drop-off step, delivered to your phone every Monday morning without opening a dashboard.
The "Continue step 2→3" row is where guests are leaving before payment — PostHog surfaces it, Telegram delivers it, Claude can fix it.
6 The Claude agent loop: errors become fixes overnight
Monitoring that only sends alerts is still reactive. The upgrade is closing the loop — alert → ticket → fix → deploy, automatically.
Nightly smoke check
A Vercel cron job at app/api/cron/smoke/route.ts runs HTTP checks against the live site. Failures trigger a Telegram alert and write a structured Markdown ticket to the backlog.
{ "crons": [{ "path": "/api/cron/smoke", "schedule": "0 3 * * *" }] } Vercel injects Authorization: Bearer $CRON_SECRET — verify this header before running any checks.
The ticket format
When a check fails, the cron handler writes a structured ticket file that Claude can read and act on:
--- status: Backlog category: claude --- # Availability API returning 502 Detected: 2026-06-17 03:00 CET Check: GET /api/availability → 502 Likely cause: Smoobu maintenance window or expired API key Action: check Smoobu status, verify API key, add retry logic if recurring
The agent loop
Claude Code can be configured to run on a schedule, scan the backlog for tickets tagged category: claude, and work through them autonomously. Errors at 3am become deployed fixes by morning without human intervention. For ambiguous issues, the agent notes its reasoning and leaves the ticket for review rather than guessing.
This kanban-driven agent pattern is described in detail in The future of AI agents looks a lot like 2010.
The reference implementation ships with three layers of automated verification:
- 122 HTTP smoke checks — every route, status code, and content string, runnable against local dev or the live URL with a single command
- 34 product evals covering language switching (10 locales), the full booking flow, GDPR consent gating, loading UX, and locale-specific regressions — 10 of them automated via Playwright
- Pricing unit tests — nightly rate × guests × cleaning fee × long-stay discount, exercised in isolation from the Smoobu API
The daily cron runs a subset of the smoke checks against the live site and writes a ticket on failure. Claude picks it up overnight. Nothing pages you unless the agent genuinely can't resolve it.
7 Security hardening
Before going live with anything that handles payments or personal data, run a systematic security review. Ask Claude to audit the codebase for these specific vectors:
| Risk | What to check |
|---|---|
| Price manipulation | Is totalPrice computed server-side from live Smoobu rates? Never accept it from the request body. |
| Webhook bypass | Is Stripe signature verification running before any event processing? Uses req.text(), not req.json()? |
| Secret exposure | Does any NEXT_PUBLIC_ variable contain a secret key? Those are bundled into the browser. |
| Rate limiting | Are /api/checkout and /api/availability rate-limited? Unprotected endpoints can exhaust your Smoobu API quota. |
| Input validation | Are all booking form fields validated and sanitised server-side before being passed to Smoobu? |
totalPrice from the browser. An attacker sends {"totalPrice": 0.01} in the checkout POST. If your server trusts it, they book a week for a cent. Recompute from live rates on every request.
8 SEO: letting Claude find your gaps
The multilingual setup from section 4 creates the surface area — but SEO is not set-and-forget. There are three jobs worth doing with Claude before and after launch.
GEO and AI search visibility
An increasing share of travel-intent searches now surface AI-generated summaries instead of blue links. Whether your direct booking site appears in those results depends on factors traditional SEO audits miss: passage-level citability, llms.txt discoverability, and structured data completeness. Claude can audit all three in a single session — fetch each page, compare it against the current ranking signals, and generate a prioritised fix list. For a short-term rental site this audit takes roughly 20 minutes and typically surfaces 3–5 high-impact gaps.
Add an llms.txt
Crawlers used by ChatGPT, Perplexity, and Google's AI Overviews increasingly respect /llms.txt — a plain-text index that tells AI systems what your site is and what each page covers. For a direct booking site, it's two paragraphs: what the apartment is, and what each route does. Ask Claude to draft one from your existing content; it takes five minutes to deploy.
PostHog for market conversion by language
Once you have traffic in multiple languages, PostHog's HogQL makes it easy to break down your booking funnel by locale. A query that groups $pageview events by the $pathname prefix (/de/, /fr/, /ja/) and joins them against purchase events gives you a per-language conversion rate table in seconds. In practice this reveals that one or two languages drive most revenue while others get traffic but convert poorly — usually a copy or calendar UX issue that Claude can fix in a targeted session.
I use a custom SEO audit workflow built on top of claude-seo — a community Claude Code skill set with 25 sub-skills covering technical SEO, GEO, schema, content quality, local search, and Core Web Vitals. Each skill runs as a parallel agent against the live URL. The repo includes an install script.
⬡ github.com/AgriciDaniel/claude-seoRun /marketing-optimize [url] after any significant content or structural change. For a multilingual STR site, prioritise the seo-geo and seo-schema sub-skills first.
9 German legal requirements
Skipping the legal pages is the fastest route to an Abmahnung. Four pages, each with specific requirements under German law:
| Page | What it requires |
|---|---|
| Impressum | § 5 TMG: full name, address, phone, email, VAT ID. Berlin STR operators: also your Zweckentfremdungsverbot permit number. |
| AGB / Terms | Cancellation policy, check-in/out times, house rules, payment terms, liability limits. |
| Widerrufsbelehrung | Short-term rentals are exempt from the 14-day consumer withdrawal right (§ 312g BGB, accommodation exception) — but you must explicitly declare the exemption. Omitting the page is not the same as declaring it. |
| Datenschutzerklärung | Every data processor named: Smoobu, Stripe, Google Analytics, PostHog, Vercel. Update whenever you add a new tool. |
Rather than writing these pages from scratch or hoping a generic template is current, use the deutsches-recht-mit-claude skill set — a Claude Code integration that pulls live statute text from the official German federal law portal (rechtsinformationen.bund.de) and checks every legal page against the current wording of the relevant laws.
⬡ github.com/waldo-van-der-code/deutsches-recht-mit-claudeThe skill reads each legal page, cites the current statute, flags missing disclosures, and generates corrected copy. Run it before every deploy that adds a new third-party service.
The most common gap: adding an analytics tool or payment method and forgetting to update the Datenschutzerklärung. The security review step catches this automatically.
Architecture
Four layers, each with a clear responsibility. The layers don't bleed into each other — the presentation layer never calls Smoobu directly, the API routes delegate immediately to lib/, and the server libraries know nothing about HTTP.
The DateRangePicker is the most complex piece in the presentation layer — it holds the two-phase click model, timezone normalization, and minimum stay enforcement. /api/webhook is the critical path: Stripe fires it on payment completion, and it creates the Smoobu reservation and sends emails in a single atomic sequence. If Smoobu changes their API, the change is contained to lib/smoobu.ts.
Go live this weekend
The core booking flow ships in a weekend. Everything else is a continuous loop that compounds over the weeks that follow — which is exactly the point of drawing the system as a circle.
Weekend — ship the core
- GitHub repo + Vercel deploy
- Smoobu availability API + calendar
- Stripe PaymentIntent + webhook
- Confirmation emails wired
- English-only launch — smoke test, go live
Ongoing — close the loop
- Add remaining languages (one evening each)
- GA4 + PostHog + cookie consent
- Telegram alerts
- Nightly smoke check + agent loop
- Security audit + legal pages
Full stack summary
| Layer | Tool | Notes |
|---|---|---|
| Framework | Next.js 16 App Router | TypeScript, Tailwind, Vercel adapter |
| Hosting | Vercel | Free Hobby tier is sufficient |
| Channel manager | Smoobu | API key: Settings → External Integrations |
| Payments | Stripe PaymentIntent + PaymentElement | Webhook-first; Apple/Google Pay automatic |
| i18n | [locale] dynamic segment | Typed TS translation files per locale |
| Analytics — funnel | Google Analytics 4 | Consent Mode v2, 3 conversion events |
| Analytics — sessions | PostHog EU cloud | Lazy init for GDPR; no pre-consent requests |
| Alerting | Telegram Bot API | Fire-and-forget, 4 alert types |
| Monitoring | Vercel cron → smoke check | Daily at 03:00 → Telegram + backlog ticket |
| Agent automation | Claude Code on schedule | Picks up backlog tickets autonomously |
| Security | Claude audit before each major deploy | 5 specific checks (see section 7) |
| Legal (DE) | deutsches-recht-mit-claude | github.com/waldo-van-der-code/… |