I'd built this operator's first web presence myself, back in December 2024: a static site of hand-maintained HTML pages served by a small Express app. It looked professional and did nothing else; it just sat there. It couldn't capture a lead in any structured way, let alone help work one. By the start of 2026, both the brief and what I could realistically build alone had changed, and the goal stopped being "a nicer website" and became "the system that runs the business behind the website." That a rebuild at this scope was a one-person job at all owes a great deal to AI-assisted development.
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 behind the back-office, 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.
A booking is a sales conversation
I modeled a booking as an inquiry rather than 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 public booking endpoint 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."
Customers get a read-only view of their proposed itinerary through a tokenized share link, with the lookup rate-limited to make guessing tokens expensive.
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. Failure paths return the same generic response, so the form does not reveal 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 a second factor. The tradeoff is that the factor is only as strong as the admin's email account.
Where Claude actually sits in the work
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 booking reply that references the customer's own stated preferences, drafting a shorter reply to a contact-form inquiry, writing SEO metadata, drafting newsletters, drafting blog posts, and a full assistant chat, with saved conversations, built into the CRM.
Most of the work went into 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. A per-admin limit of sixty AI calls an hour caps runaway usage. Almost none of that work was prompting. It was the boundary around the model.
Recovery and record-keeping
Deletes are soft, with a unified trash that can restore or permanently purge across bookings, blog posts, reviews, and inquiries from one place. Consequential actions are written to an audit log, which is best-effort and does not throw, so a logging failure does not fail the operation around it, 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 that is where untrusted markup enters the system, and the redundancy is cheap. Newsletters ship with one-click unsubscribe headers and per-recipient unsubscribe links.
What I would do differently
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.
Scale
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, by design, and there is no checkout: deposits and payments are still handled offline. A non-technical two-person team runs their day-to-day sales and content work in it themselves. Deposits and payments still happen offline, by design.
