This site looks like a portfolio, but it quotes prices, answers questions about my work with a language model, and logs visits without a tracking script. All of that runs on free tiers, deploys on every push, and refuses to deploy when a check fails. This post is the map.
The shape#
Browser
Static HTML and JS from Vercel; RxJS stores hold page state
api.drbharada.com
Cloudflare Worker (Hono): validation, rate limits, Workers AI, Access-guarded docs
Google Apps Script
Writes enquiries, questions, briefs and visits into a private Sheet
Google Sheet
The database: four tabs, 90-day retention on the logs
Three rules decided most of it:
- No server for the pages. Next.js with
output: "export"produces plain files. Nothing to patch, nothing to scale, and a bad deploy is a file diff. - One small server for what can't be static. Form submissions, the AI features and the visit log go through a Worker. It validates everything, applies rate limits, and is the only thing that holds secrets.
- The spreadsheet is the database. For a one-person site, a Sheet with an Apps Script in front of it is enough, costs nothing, and I can read it on my phone.
The static side#
Pages are thin: they load content from MDX and TypeScript files and compose components. State that lives longer than a render (the project planner, the ask box, the audio player) sits in small RxJS stores modelled on NgRx ComponentStore: one state stream, selectors, effects as streams. Components read them with a useObservable hook. That's the same pattern I use in enterprise Angular work, and it means the logic is testable without a browser.
Two files are generated at build time for machines rather than people: /llms.txt, a plain-text summary of the site's content, and /planner-catalog.json, the planner's services, scope examples and rates. Both come from the same content files as the pages, so they can't drift.
The Worker#
The API is a Hono app with zod-openapi, so every route's schema is both runtime validation and documentation. Swagger UI is served by the Worker itself but sits behind Cloudflare Access; the Worker verifies the Access JWT on each request rather than trusting a header, and fails closed if the verification keys can't be fetched.
The two AI routes use Workers AI. /v1/ask answers questions only from /llms.txt, with a system prompt that says to admit when the answer isn't on the site. /v1/brief turns a plain-words project description into a priced brief. The model writes the scope lines in the visitor's own terms, but it can only pick services, size bands and add-ons from the published catalogue, and the Worker re-validates and prices every line itself. The model never sets a rupee figure.
The shared contract#
The Worker and the planner both need the brief's shape and the same validation. Writing that twice is how two sides drift, so it lives in one package, @dr-bharada/contract: the types plus a single normalizeBrief(raw, catalog) function. The site consumes it as an npm workspace, the Worker as a file dependency, and the Worker's zod schemas are declared satisfies the package types, so a mismatch fails the type-check before anything deploys. The package is also published to GitHub Packages on a version tag, for the record.
Analytics without tracking#
Each page view sends a beacon to the Worker. The Worker hashes the IP with a secret and the date (so the hash can't be reversed or followed across days), keeps country and region but not city, and the Apps Script appends a row. The same visitor ID (a random value in local storage) links a visit to a question or a brief, which is the behaviour I actually want to see. The privacy page says exactly this, and the logs delete themselves after 90 days.
What stops a bad deploy#
Every push runs the same gate locally, in GitHub Actions and in the Vercel build: ESLint, Prettier, TypeScript, unit tests. Then the static export is tested with Playwright on ten pages at phone and desktop widths: the site's own UX rules as assertions, axe for accessibility, screenshot baselines, and the planner end to end with the API mocked. Lighthouse runs last with SEO and accessibility as hard gates. The Worker has its own route tests and runs them before wrangler deploy. Every six hours a workflow checks the live site, the API version and the Apps Script.
What I'd change#
The Apps Script is the one piece outside the pipeline: it's deployed from the terminal with clasp and tested by loading its helpers into Node with Google's services stubbed. If the logs ever outgrow a Sheet, they move to Cloudflare D1 and that gap closes. Until then, a spreadsheet is the right size.
Sources: Next.js static exports, Cloudflare Workers AI, Hono zod-openapi, Lighthouse CI.