This site is two experiences wearing one domain. The homepage is a scroll-driven editorial landing page. `/journey` is a curated 3D toy-railway diorama — a stylised locomotive gliding through five stations of my career. Both are Next.js 16, both share the same content layer, and as of this year, both are reachable from a chat widget that can actually answer questions about me.
The stack, briefly
Next.js 16 App Router with React Server Components, React 19, Tailwind CSS v4 using its `@theme inline` token system rather than a config file. Firebase (Firestore + Storage + Admin SDK) for anything that needs to persist. Resend for transactional email. Zod for every form and API boundary. Three.js + React Three Fiber for `/journey`, kept strictly out of the homepage's bundle.
The animated hero is its own small essay — a 119-frame WebP sequence drawn to a canvas and scrubbed by scroll position, chosen specifically because swapping an `<img src>` on every scroll tick produces a visible flicker on slower devices. I wrote that one up separately; the short version is that `drawImage()` is atomic and `src=` is not.
Content as data, not a CMS
Projects, work experience, and writing all live as typed arrays — `lib/projects.ts`, `lib/experience.ts`, `lib/writing.ts` (yes, this post is data describing itself). No headless CMS, no database round-trip for content that changes maybe once a month. Each file is the single source of truth for its domain, consumed by the relevant page components directly.
That decision paid for itself in a way I didn't originally plan for: when I built the chat widget, the knowledge base was just "import the same three files and compile them into a system prompt." No separate content pipeline, no keeping two sources in sync. The bot knows exactly what the site says, because it's reading the same array.
Why a chatbot, and why not a form
The site used to have a floating mascot that nudged visitors toward `/journey`. It was charming and did exactly one thing. I replaced it with a widget that can answer real questions — "what's Saurabh's experience with Next.js," "how do I reach him," "what's his best project" — grounded in the actual site content and a live resume, not a canned FAQ.
The one constraint that shaped everything downstream: it runs on a free-tier LLM API (Zhipu AI's GLM), which means a shared quota across every visitor to the site. A single bot or a slow news day of traffic can exhaust a free tier that isn't rate-limited properly. Most of the design below exists because of that one constraint.
Streaming without a Server Action
The obvious Next.js instinct is to reach for a Server Action. I didn't, because Server Actions use React's action-encoding protocol — they're not a plain `fetch()`, so a `fetch`-based streaming client fights the framework instead of using it. A Route Handler (`app/api/chat/route.ts`) returning a streamed `Response` is the idiomatic path: plain `fetch()` in, a `ReadableStream` of UTF-8 text-deltas out, no client-side SSE parsing needed.
Server-side, the handler re-emits GLM's own SSE stream as plain text chunks, so the browser just appends bytes as they arrive. No AI SDK, no extra dependency — GLM's API is OpenAI-compatible, so it's a raw `fetch` in both directions.
The resume problem
The resume is a PDF that gets re-uploaded independently of code deploys — it needed to be readable without requiring a redeploy every time it changed, but also without hitting Firebase Storage on every single chat message. The answer is a module-scoped cache with a one-hour TTL: fetch and parse (via `unpdf`, not `pdf-parse` — the latter does a synchronous `fs.readFileSync` against a bundled fixture at import time, a known footgun under serverless bundling) on first request, serve from memory until the hour is up, then refresh transparently on the next request after that.
Concurrent requests during a cache miss coalesce onto a single in-flight fetch rather than each firing their own — otherwise a burst of traffic right as the cache expires would trigger a thundering herd against Storage.
Guardrails as an actual written contract
The system prompt isn't just "be helpful." It's a short spec: what's in scope (me, my work, my resume — nothing else), how to handle being asked if it's a bot (answer honestly, stay warm about it), what to do when it doesn't know something (say so, don't invent), and explicit instructions to treat the reference material as data rather than instructions — which matters because a resume is user-uploaded-ish content that a prompt-injection attempt could theoretically hide text inside.
There's also a formatting contract most people wouldn't think to write down: the chat UI renders a small, deliberate subset of markdown (bold, dash-lists) and nothing else. Early on the model would occasionally reach for headings or backticks the UI doesn't understand, which just showed up as literal asterisks and hash marks in the bubble. The fix wasn't a rendering hack — it was telling the model exactly what's safe to use.
Two ways to run out of quota, two rate limiters
Per-IP throttling stops one visitor from hammering the endpoint, but it doesn't protect the shared free-tier budget from ordinary aggregate traffic across every visitor — and it can't, because Vercel runs multiple serverless instances with no shared memory between them. An in-memory counter on one instance knows nothing about the requests landing on another.
The actual protection is a Firestore transaction against a day-keyed document — every visitor's request increments the same counter, and a new UTC date is automatically a fresh document, so there's no cron job resetting anything. The two limiters answer different questions: "is this one visitor going too fast" and "has the whole site used its daily budget," and you need both.
Ephemeral by design
Nothing about a conversation's content is stored server-side, anywhere. Messages live in the browser tab's state for the session and vanish on refresh. That wasn't the default I'd have picked for debugging convenience, but it was the right call for a widget that might see people asking candid questions about a stranger's career — there's no reason to be the system holding onto that.
What actually broke, twice
The free-tier model name I'd guessed at launch (a plausible-looking placeholder) simply didn't exist on the account — the API said so plainly once I had a real key to test against. The model that did work turned out to be a reasoning model: left to its defaults, it spent its entire token budget on internal chain-of-thought and never reached an actual answer. The fix was a single request field disabling the reasoning phase — but finding that took reading the actual streamed output chunk by chunk, not just the docs.
Second: closing the chat panel mid-response — tab closed, navigation, or just a fast click — left the server still trying to write to a stream nobody was reading, throwing the same error into the logs on every subsequent chunk. `ReadableStream` has a `cancel()` hook for exactly this; the fix was routing the upstream GLM reader through it so a disconnect actually stops the read loop instead of failing silently, repeatedly, into a log file.
Neither bug was visible in a quick manual test. Both showed up only once I stopped treating "it returned 200 once" as proof of correctness and started reading server logs after every change.
— Saurabh