Skip to content

People who book a call often ask how this site works, because it does more than a portfolio usually does: you can describe a project in plain words and get a priced estimate, ask questions about my work, or download a PDF brief. This post is the whole map, one piece at a time.

The short version: the pages are plain static files, and everything that needs a server goes through one small API.

  1. Static site

    Next.js export on Vercel

  2. API

    A Cloudflare Worker with typed routes

  3. AI

    Workers AI: Ask box and Project Scribe

  4. Inbox

    Apps Script, a private Sheet and email

The whole system, top to bottom

Here is the same system as a map, with every page, route and data store on one screen:

Mind map of drbharada.com: pages, services, the Project Scribe planner, content files, the API, contact channels, the Google Sheet and the quality checks, all around a central DR Bharada node

The front end: static first#

The site is Next.js (App Router) with React, TypeScript, Tailwind CSS and a few SCSS partials, built with output: "export". Every page is rendered to HTML at build time, so there is no Node server in production. Vercel serves the out/ folder and redeploys on every push.

Static has three practical benefits for a site like this:

  • Fast and cheap. Pages come straight from a CDN.
  • Nothing to patch at runtime. There is no server process to keep alive or secure.
  • Content is just files. A blog post or case study is one .mdx file. The list pages, RSS feed, sitemap, share image and related posts all pick it up at build time.

Content that drives behaviour lives in typed TypeScript files rather than a CMS. The list of services, their scope items and their rates is one file, and the Services page, the planner and the contact form all read from it, so they can't disagree.

Client-side state uses small RxJS stores modelled on NgRx ComponentStore: one BehaviorSubject per store, select() for derived values and patch() for updates. If you write Angular, it will look familiar, which was the point.

The API: one Worker, typed routes#

Anything that needs a secret or a model goes through api.drbharada.com, a Cloudflare Worker written in TypeScript with Hono and zod-openapi. Each route's request and response are zod schemas, and the OpenAPI spec and a private Swagger page are generated from the same code, so the docs can't drift from the routes.

| Route | What it does | |---|---| | POST /v1/contact | Accepts a contact-form enquiry | | POST /v1/ask | Answers a question about my work | | POST /v1/brief | Turns a plain-words project description into a priced brief | | POST /v1/visit | Records an anonymous page view | | GET /v1/services | Lists the services the form accepts | | GET /v1/health | Version and status, used by the uptime check |

The /v1/ask route is rate-limited with Cloudflare's rate-limiting binding, so one visitor can't run up the model allowance.

The AI parts: grounded, then validated#

Two features use Cloudflare Workers AI. Both follow the same rule: the model only works from material the site publishes, and its output is checked before anyone sees it.

Ask about my work. The site generates /llms.txt, a plain-text summary of my services, case studies and articles. The Worker reads it and asks the model to answer only from that text. If the summary doesn't cover the question, the model is told to say it can't tell from the site and point to email, and never to invent numbers or prices.

The Project Scribe on the planner. You describe a project in your own words. The Worker reads /planner-catalog.json (services, scope items, add-ons, timelines and rates, generated from the same content file as the Services page) and asks the model for JSON in a fixed shape. The model writes each scope line in your terms and sizes it S, M, L or XL.

The model never sets a price. A shared package, @dr-bharada/contract, holds one function, normalizeBrief, that both the Worker and the site run on the model's output. It drops unknown services, clamps quantities and prices each line from the service's effort-band rate in the catalogue.

  1. Visitor

    Describes the project in plain words

  2. Catalogue

    Services, bands and rates from the site

  3. Model

    Scope lines in fixed JSON, sized S to XL

  4. normalizeBrief

    Checks the catalogue, prices each line

  5. Planner

    Live estimate, Review and a PDF

Project Scribe, from words to estimate

Pricing in code rather than in the prompt is the part I'd recommend to anyone building something similar: a model can be persuaded to say anything, but a lookup table can't.

The inbox: a Google Sheet#

There is no database. The Worker forwards enquiries, questions, briefs and visits to a Google Apps Script web app, which writes a row to a private Google Sheet and emails me about new enquiries. The Sheet has one tab per kind: Enquiries, Questions, Visits and Briefs.

  1. Form or planner

    Sent as JSON from the browser

  2. Worker

    Validates the fields, adds anonymous details

  3. Apps Script

    Checks the key, filters spam

  4. Sheet and email

    One row per message, plus an email

Where an enquiry goes

For a one-person practice this is the right size. I can read, filter and reply from my phone, and there's no schema migration when I add a column. The Apps Script checks a shared key, filters spam and writes cells in a way that can't be read as spreadsheet formulas.

Visit logging is anonymous by design: a visitor ID kept only in your browser, country and region, and an IP hash salted daily so it can't be joined across days. Nothing is logged from local development or preview builds. The privacy page lists every field.

The safety net: checks on every deploy#

The parts above only stay working because every change has to pass a gate before it ships:

  • npm run check runs ESLint, Prettier, the TypeScript compiler for the site and the shared package, and the unit tests (Vitest). A failed check means no deploy.
  • Unit tests cover normalizeBrief, the planner store (totals, session restore, the brief text) and the Apps Script helpers.
  • Worker tests call every route in-process: validation, status codes, CORS and the access guard on the private routes.
  • Playwright tests load key pages at phone and desktop widths and assert the UX rules: no sideways scroll, readable text sizes, tap targets that are big enough, an axe accessibility scan, and screenshot comparisons.
  • An uptime check runs every six hours against the live site and the API.
Automated tests by suite
Unit (Vitest)23
API routes (Worker)16
Browser (Playwright)68
View data
ItemTests
Unit (Vitest)23
API routes (Worker)16
Browser (Playwright)68
Counted from the repository on 24 October 2026: Vitest results, the Worker's test file and Playwright's test list.
  1. Commit

    Pre-commit hook runs ESLint and Prettier

  2. Push

    CI runs every test suite

  3. Vercel build

    npm run check, then the static export

  4. Live

    Published only if npm run check passes

From a commit to the live site

What I'd keep if I started again#

  • Static pages, one small API. It keeps the moving parts in one place and the rest boring.
  • One source of truth for content that drives behaviour. The service list feeds the page, the planner, the form and the AI catalogue.
  • Let the model write words, and let code do the arithmetic. It's the reason the estimates are consistent.
  • A shared contract package for anything the front end and the API must agree on, so a change is one edit in one commit.

If you're weighing a similar setup for a product of your own, you can plan a project and the Scribe will turn your description into a first estimate.

Sources#