When I started, the operator's web presence was a legacy static site: dozens of hand-maintained HTML pages served by a small Express app. It looked professional and did nothing else. It could not capture a lead in any structured way, let alone help work one. So once I understood the business, the brief stopped being "build a nicer website" and became "build the system that runs the business behind the website."

That reframing is the whole project. What I shipped is two things at once: a fast, SEO-heavy marketing site, and a real back-office behind it. This is the account of the decisions that made the back-office trustworthy, because that is where almost all of the engineering went.

One application, not two

I built the whole thing as a single Next.js 16 App Router application. The public marketing pages and the /admin operations console live in the same codebase, with the admin routes gated by middleware. There is an escape hatch I am glad I built: one environment flag promotes the admin console onto its own subdomain, with its own stricter content-security-policy and a noindex profile, by flipping the middleware's rewrites and cookie scoping. No second deployment, no duplicated code.

The reasoning was "ship simple, isolate later." A two-person business does not need a microservice split on day one. It does need the option to harden the admin surface without a rewrite, and a feature flag buys that cheaply.

Treating a booking as a sales conversation, not a checkout

The most important modeling decision was to make a booking an inquiry, not a transaction. Safari itineraries are negotiated: a proposed route, a proposed price, a back-and-forth, then a deposit handled offline. So there is no checkout. A booking is a lead that moves through an enforced status pipeline (pending, confirmed, completed, cancelled), and the code rejects illegal transitions rather than trusting the UI to prevent them. Confirming a booking automatically fires a confirmation email; the state machine is the single place that decides what is allowed.

The detail I am most quietly proud of is the ordering in the public booking endpoint: it writes the lead to the database first, then sends notification emails inside a separate try/catch. If the email provider has a bad day, the business still has the lead. An outage degrades to "we did not get notified," never to "we lost a customer." That is a one-line ordering decision that changes the failure mode entirely.

Customers get a read-only view of their proposed itinerary through a tokenized share link, with the lookup rate-limited so the tokens cannot be guessed by brute force.

Auth that takes a two-person team seriously

The admin login is two-factor, but not the kind that needs an authenticator app. A login checks an email and a bcrypt-hashed password against an environment allowlist, then emails a six-digit one-time code. The code is hashed at rest, single-use, expires in fifteen minutes, and locks out after five attempts. The allowlist fails safe: an empty list means nobody can log in, not everybody. Every failure path returns the same generic response so the form cannot be used to enumerate which emails are admins.

I chose emailed OTP over a third-party identity provider deliberately. For a two-person team, it reuses the email infrastructure the app already has and adds no authenticator-app friction, while still being real second-factor protection. It is the right amount of security for the actual threat model.

Wiring Claude into the work, not bolting on a chatbot

There is no public chatbot. Instead, Claude is wired into seven specific operator workflows, on the exact admin screens where the work happens: generating a structured itinerary that is parsed straight into the itinerary editor, drafting a reply that references the customer's own stated preferences, writing SEO metadata, drafting newsletters, and drafting blog posts.

The interesting part is the cost and reliability engineering around it. The assistant's system prompt is large and deliberately frozen, with no interpolated dates or IDs, so it exceeds the cacheable-prefix threshold and every turn after the first reuses the cache instead of paying for the full prompt again. Cheap work like conversation titles runs on Haiku; the heavier generation runs on Sonnet. The streaming endpoint persists the user's message before it starts streaming and saves the assistant's reply even if the stream is cut off, so a dropped connection never loses work. A per-admin hourly limit caps spend. This is the same lesson I keep relearning: the model is the easy part, and the engineering is in the boundary around it.

The unglamorous half

Most of the code, and most of what makes the system trustworthy, is the part nobody demos. Deletes are soft, with a unified trash that can restore or permanently purge across bookings, blog posts, reviews, and inquiries from one place. Every consequential action is written to an audit log, which is best-effort and never throws, so logging can never take down a real operation, and those entries are joined into a per-customer timeline. User-supplied HTML is sanitized both when it is stored and again when it is rendered, because defense in depth at the one place untrusted content enters the system is worth the redundancy. Newsletters ship with one-click unsubscribe headers and per-recipient unsubscribe links.

What I would do differently

I want to be honest about the gaps, because the honesty is more useful than the polish.

There are no automated tests yet. For a system with a booking state machine and money-adjacent workflows, that is the first thing I would fix: integration tests around the pipeline and the auth flow, before anything else. The rate limiting is in-memory, which means it resets on serverless cold starts and is best-effort rather than a hard guarantee; the correct fix is a shared store like Vercel KV or Upstash. The transactional emails are inline HTML strings inside the route handlers, which is pragmatic for email-client compatibility but a maintainability cost; a templating library would be the cleaner path. None of these are hard. They are the next pass.

Scale, honestly

Roughly forty-four thousand lines of TypeScript, about seventy public pages and twenty-five admin pages, forty-eight API route files, and a thirteen-table PostgreSQL schema through Prisma. It is not multi-tenant, and it has no payment processing, both by design. The number I actually care about is none of those: it is that a non-technical two-person team can run their entire sales and content operation out of it without me in the loop.