# Grandma: production-grade AI chat, on a shoestring

> How I built grandma.chat — a Next.js + AI Gateway chat app — with layered abuse defense, dual in-memory/Mongo backends, and a daily spend circuit-breaker.

By Jeff Mixon, published 2026-06-19. Canonical: https://www.jeffmixon.com/post/grandma/

## What is Grandma?

[grandma.chat](https://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-server` for 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:

```ts
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 dev` boots 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.
