What is Grandma?
grandma.chat is a small PWA where you text with a warm, stereotypically-American grandmother — for advice, for comfort, for recipes, for whatever the moment needs. Under the hood she is a streaming LLM with a tightly-tuned system prompt and an in-browser conversation buffer; on the surface she will absolutely tell you to put on a sweater.
I built her as a small demonstration of some of my full-stack capabilities — end to end, by myself, from product surface to deployment. The product surface is intentionally small. The engineering underneath is the more interesting part. This post is about what’s underneath.
Stack at a glance
- Frontend: Next.js 16 (App Router), React 19, TypeScript 5 strict, Tailwind 4
- LLM: Vercel AI SDK 6, routed through Vercel AI Gateway
- Persistence: MongoDB Atlas (driver 7), with
mongodb-memory-serverfor tests - Auth: NextAuth v5 (Google, plus a dev Credentials provider)
- Tests: Vitest 4
- Runtime: Vercel (Node), streaming responses
- CI/CD: GitLab CI → Vercel CLI
Everything except the model providers themselves runs on a single Vercel project and a single Atlas cluster. No Redis, no queue, no separate worker tier.
Defense in depth
A public LLM endpoint, treated naïvely, is an unbounded cost surface: anyone on the open internet can spend my money one HTTP request at a time. So before any of the product work, the very first thing I designed was a defense-in-depth posture for the wallet — several independent layers between the open internet and the model provider, each cheap to enforce, each able to fail without taking the others with it.
The full ladder stays in the codebase; making an adversary work to map it is half the point. What’s worth showing in public is the most novel rung: a global daily USD spend ceiling. The chat endpoint asks “is there budget left today?” before invoking the model and reports the call’s estimated cost after. Once the day’s total crosses the ceiling, the endpoint refuses — for everyone, for the rest of the UTC day. The interface is a deliberate eight lines:
export interface SpendGuard {
check(): Promise<boolean>;
record(costUsd: number): Promise<void>;
}
export const spendGuard: SpendGuard = process.env.MONGODB_URI
? new MongoSpendGuard()
: new InMemorySpendGuard();
It is coarse on purpose. The check is read-only and the record happens after streaming, so a burst of concurrent calls can overshoot the budget slightly — an acceptable trade for a cheap, hot-path-friendly circuit-breaker. The worst case is now bounded: whatever the rest of the stack misses, the wallet does not catastrophically empty.
Architecture: dual-implementation seams
Each service that needs persistence — the user store, the rate limiter, the auth backend, the ad selector, the spend guard — sits behind a small interface with two implementations: an in-memory one and a MongoDB one. Runtime selection happens once, at module load, on whether a database URI is configured.
Three things follow:
- Local dev needs zero infrastructure.
pnpm devboots a fully functional server with no Mongo, no Redis, no Docker Compose. - Tests run the real code paths. Vitest exercises route handlers end-to-end against the in-memory backends. No mocks, no surface the integration suite can’t reach.
- The vendor swap path is honest. When Mongo becomes the wrong answer — say I want Postgres for transactional users and Redis for rate limits — I have one interface to re-implement per concern, not a scattered grep-and-replace across the app.
It is the simplest pattern that turns “I want to be able to change my mind later” from a slogan into a property of the code.
One database, no Redis
The natural reach for rate-limit and spend counters is Redis. I didn’t reach. A small helper sets up MongoDB TTL indexes so Atlas auto-purges expired counter documents; the rate limiter and the spend guard both lean on it. The result: one managed service, one connection pool, one bill, and one place to look when something is off. There is a real performance ceiling at which this becomes the wrong choice — Grandma is well below it.
AI Gateway: model routing without provider lock-in
All model calls go through the Vercel AI Gateway. The app sees a single API; the gateway handles provider failover, per-model cost telemetry, and rate-limit aggregation. Two tiers — a fast, cheap model for the free experience and a more capable one for the premium tier — are selected by a single configuration value. Swapping either tier, or adding a third, is a config change, not a code change. The leverage is operational: when a provider has an incident at 2am, the gateway routes around it without me being awake.
Auth: HMAC bearer tokens and the anon-to-authed handoff
Two interlocking pieces:
- HMAC-signed bearer tokens with TTL for native clients. Production refuses to boot if the session-signing secret is left at its development default — a forgeable session secret in production would be a real vulnerability, not a paper one, so the only safe choice is to crash loudly.
- Anonymous → authenticated bucket transfer. When a user signs in mid-conversation, the session-issuance callback moves today’s rate-limit consumption from the anonymous identity onto the new authenticated one. Otherwise a determined user could anonymously burn through the free quota, sign in, and mint a fresh one. It is a five-line fix to a problem nobody would notice for weeks.
CI security posture
The CI pipeline gates merges on Semgrep SAST, npm audit, the OSV scanner,
and Gitleaks. The point isn’t the tools — they are all standard — it’s that
a solo personal project runs the same supply-chain gates I would expect on
professional work. Drift between how I ship for clients and how I ship for
myself is exactly how bad habits leak into client work.
Closer
The PWA install path, the service worker, and the native-app readiness seams elsewhere in the codebase are real and shipping, but they aren’t load-bearing for the engineering story I wanted to tell here. The story is that you can ship a small AI product, alone, without it becoming an operational or financial nightmare, if you spend the upfront budget on layers and seams. Grandma is the proof.