Skip to main content
Case StudiesProjectsAbout
Case StudiesProjectsAbout
Project

Cravia: Stop Guessing. Order Right.

A dish-level restaurant review app where diners rate individual menu items on taste, portion, and value — with photos, bill verification, and a points economy — so they know what to order, not just where to eat.

12 min read
Next.js 16React 19TypeScriptFirestoreTypesensePWA
Live Demo
~34.7k LOC
Codebase
347 files · 40 API routes · 27 repositories
7, all optional
External services
boots on Firebase alone
<50ms
Search latency
Typesense, typo-tolerant (target)
My Role
Solo: product, design, architecture, engineering, data, deployment
Timeline
~12 weeks, 2026
Team
1 engineer
Platform
Web + installable PWA
Stack
Next.js 16 · Firestore · Typesense · Upstash · Vercel
Status
Feature-complete MVP · Gurugram soft launch

Problem

Restaurant-level star ratings tell you a place is "4.3 stars" but not which of its 40 dishes is worth ordering. The signal you actually need at the table — "is the paneer tikka good here?" — is buried in prose or missing entirely. As the landing hero puts it: "4.3 stars, 1,200 reviews. Zero about the food."

Solution

Model the dish, not the restaurant, as the primary reviewable entity. Every review carries three orthogonal sub-ratings (taste / portion / value), structured tags, an optional photo, and an optional bill photo for verification. Dishes hold server-computed rolling averages so the whole catalog is sortable by dish quality. The key insight: dish-level reviews only become useful at volume, so the app runs an incentive loop — a "Crumbs" points economy tuned so quality pays more than quantity.

Overview

Cravia is a Next.js 16 App Router application built solo over roughly twelve weeks. The public catalog — landing, explore, dish, restaurant, and cuisine pages — renders as Server Components with ISR (1-hour revalidation) for SEO and speed. The interactive surface — write-review, rewards, settings, admin, and owner dashboards — is client-rendered against a strict write-through-API boundary. It's a full product, not a toy: auth, payments, a points ledger, restaurant-owner claim and verification, an admin moderation console, transactional email, and a PWA install path.

The reason it's built this way is a deliberate stance on the client/server boundary. Firestore's security-rules model tempts you to let clients write directly. I refused that for anything with side effects: every mutation goes through an /api/* route authenticated with a Firebase ID token, validated with Zod, rate-limited via Upstash, and executed through a repository layer using the Admin SDK. Only two client-side writes are sanctioned — user-doc creation on first sign-in, and a whitelisted profile update — both fenced by Firestore rules. This centralizes validation, rate-limiting, rewards accrual, search-index sync, and cache invalidation in one place instead of scattering them across the client.

The core technical challenge was consistency without a relational database. Firestore has no joins and no cross-query transactions, so denormalized aggregates — dish rolling averages, user review counts, levels, badges, points balances — must stay correct under concurrent writes, while secondary systems (Typesense, the analytics cache) must stay eventually consistent without blocking the user's request. Almost all of the interesting engineering lives in how those invariants are maintained.

Architecture

The browser reads public catalog data through Server Components and a repository layer (the client Firebase SDK, allowed by security rules). It writes only by calling /api/* routes with a Bearer <Firebase ID token> header. Each route verifies the token, rate-limits, Zod-validates, then performs the write via the Admin SDK (which bypasses rules). Search is delegated to Typesense with a Firestore prefix-search fallback. Rate-limiting and hot analytics live in Upstash Redis, each with its own fallback path. This structure exists so that the authoritative data (Firestore) and every derived system — search index, analytics cache, points ledger, notifications, email — are mutated from a single trusted server context.

request lifecycle: a review write
Browser (RSC pages + client components, PWA shell)
   │  reads (client SDK, rules-gated)      │  writes (fetch + Bearer ID token)
   ▼                                       ▼
hooks → repositories → Firestore     /api/* route handler
                                       ├─ getRequestAuth()  → hand-rolled RS256 verify (node:crypto)
                                       ├─ checkRateLimit()  → Upstash sliding window ─┐ fallback: in-memory Map
                                       ├─ parseBody(zod)                              │
                                       └─ repository (Admin SDK) → Firestore txn      │
                                             │ (fire-and-forget, .catch → Sentry)     │
                 ┌───────────────────────────┼──────────────────────────┐            │
                 ▼                            ▼                           ▼            ▼
       Typesense upsert          invalidate analytics cache    milestone notif/email   Upstash Redis
             │ fallback ↓             (Redis → Firestore)         (Resend, best-effort)
       Firestore prefix search

Async behavior: review creation performs its critical write inside a Firestore transaction, then fires off Typesense sync, analytics-cache invalidation, cover-image backfill, and milestone notifications as non-blocking, .catch()-guarded promises. There is no queue — side effects must complete within the serverless request window, a known scaling limit called out in the engineering docs.

Tech Stack
Next.js 16React 19TypeScript (strict)FirestoreFirebase AuthTypesenseUpstash RedisZustandTanStack QueryZodTailwind v4RazorpayCloudinaryResendSentrySerwist (PWA)Vercel

Engineering Deep Dive

Hand-rolled Firebase ID-token verification

The challenge: verify a Firebase ID token on every authenticated API call, in a serverless environment, without paying Admin-SDK cold-start cost on the auth hot path — and without trusting the token blindly.

lib/auth/firebase-auth-provider.ts

verifyFirebaseIdToken() is implemented from scratch: split the JWT, base64url-decode the header and payload, require alg === 'RS256' and a kid, fetch Google's x509 certs, and verify the signature with createVerify('RSA-SHA256'). It then validates exp, iat, sub length (≤128), aud === PROJECT_ID, and iss === https://securetoken.google.com/<project>.

The non-obvious part: Google's certs are cached in module scope, and their TTL is parsed out of the response's Cache-Control: max-age header (certsExpireAtMs) — so certs refresh exactly when Google rotates them, not on a hardcoded interval. getRequestAuth() then wraps the user lookup in React's cache(), so multiple getById(userId) calls within one request dedupe to a single Firestore read.

◆Decision — Token verification

Hand-rolled RS256 verification with node:crypto. Rolling my own avoids Admin-SDK init cost on the auth path and gives full control over cert caching (refresh on Google's actual rotation cadence). The trade-off is real: I now own crypto-adjacent security code that Firebase would otherwise maintain — a genuine risk if a validation step is ever dropped. It's justified only because every check is covered and the hot path runs on every request. (Considered instead: adminAuth.verifyIdToken(), a third-party JWT library.)

Atomic denormalized aggregates under concurrent writes

With no joins, dish quality (avgTaste / avgPortion / avgValue / avgOverall, reviewCount) and user gamification (reviewCount, level, badges) are denormalized onto their documents. Concurrent reviews on the same dish must never corrupt these rolling averages.

lib/firebase/reviews-admin.ts

createReview() runs an adminDb.runTransaction() that reads the dish and user docs, computes the incremental new average — ((avg * prevCount) + newRating) / (prevCount + 1) — writes the review, updates dish stats, and recomputes the author's level and badges, all atomically. updateReview() and deleteReview() apply the inverse arithmetic — (avg * count - old + new) / count — so edits and deletes stay consistent without a full recompute.

Details worth noting: topTags is recomputed outside the transaction by re-reading the dish's reviews (accepted staleness — tags are lower-stakes than ratings). voteHelpful / unvoteHelpful are idempotent and reject self-votes inside the transaction, pairing FieldValue.arrayUnion / arrayRemove with increment so the count and the voter set can't drift. Duplicate reviews are blocked by a pre-transaction query on (dishId, userId).

A quality-gated rewards economy

A points incentive invites spam. Points have to reward genuine, substantive reviews and resist farming.

lib/services/rewards.ts

computeReviewPoints() tiers awards — 10 (basic) / 20 (photo) / 25 (bill-verified) — but a photo or bill review is downgraded to basic unless it passes passesTextQualityCheck(): the text must contain ≥5 distinct words (countDistinctWords via a Set) and must not duplicate any of the user's last 3 reviews. Streaks are computed by pulling the 50 most recent point transactions, collapsing them to unique UTC calendar days, and counting consecutive days backward; a 2× bonus fires only when the streak is a nonzero multiple of 7.

Anti-spam beyond scoring: createReview() runs a burst detector — ≥3 reviews for the same restaurant within 30 minutes emits a same_restaurant_burst integrity event (logIntegrityEvent) without blocking the write, producing a moderation signal instead of a hard rule. Milestone notifications (250 / 450 Crumbs) are gated on a crossing check (prevTotal < M && newTotal >= M) so they fire exactly once, and the milestone email is best-effort (.catch → Sentry).

Two-tier caching, graceful degradation, and payment integrity

Serverless has no shared memory or persistent connections, so an in-process cache resets on every cold start — yet Firestore reads for owner analytics are expensive.

◆Degradation as a design principle

lib/services/analytics-cache.ts is Redis-primary, Firestore-secondary: a read tries Upstash (survives cold starts, shared across instances), falls through to a restaurants/{id}/cache/analytics Firestore doc, each with a stored expiresAt; writes populate both. lib/rate-limit.ts mirrors the pattern — Upstash slidingWindow per tier (review-create 5/hr, redeem 3/hr, general 60/min) falling back to an in-memory Map — and logs a one-time Sentry warning in production if Upstash is unconfigured, since the fallback can't enforce limits across serverless instances.

Payments are handled with the same rigor: verifyPaymentSignature / verifyWebhookSignature use HMAC-SHA256 with crypto.timingSafeEqual; prices live server-side in PLAN_PRICES (₹199 / ₹1999 stored as paise integers, never floats); premium activation is a batched write; and the webhook is idempotent via a razorpayEventKey dedup query against billingEvents.

Key Decisions

◆Decision — Primary database

Cloud Firestore. Zero-ops, serverless-native, real-time listeners, and security-rules-as-a-read-API made Firestore a fast path to a full product. I accepted the costs deliberately: no joins (forcing denormalization plus the transaction arithmetic above), no native full-text search (forcing Typesense), and vendor lock-in. (Considered instead: Supabase / Postgres, PlanetScale, self-hosted Postgres.)

◆Decision — Write path

Server-mediated writes (/api/* only). One trusted place for validation, rate-limiting, rewards, search sync, and cache invalidation beats scattering side effects across the client or across function triggers. The trade-off: more boilerplate per mutation, and side effects run in-request with no queue — they must finish inside the serverless timeout. (Considered instead: Firestore rules + direct client writes, Cloud Functions triggers.)

What's strong

  • Clean layering: hooks → repositories → firebase, with services for business logic
  • Every third-party is optional and fails soft — the app runs on Firebase alone
  • Strong consistency on the fields that matter, via transactions
  • Genuine defense-in-depth: rules + server verify + rate limit + Zod + HMAC

What's weak

  • Review side effects are serialized into one request with no queue (latency + partial-failure surface)
  • helpfulVotedBy is an unbounded array that will hurt at scale
  • Denormalized userName / userLevel / avatarUrl on reviews go stale with no backfill
  • The in-memory rate-limit fallback offers near-zero protection without Upstash

Results

Cravia is a feature-complete, single-city MVP. The full loop — discover → review with photo and bill → earn Crumbs → redeem coupons — is live, alongside premium subscriptions, owner claim/verification and analytics dashboards, and an admin moderation console, all wired to real services and shipped as an installable PWA. As a solo, ~12-week build it demonstrates end-to-end ownership across product, design, data ingestion (Google Places scripts), and serverless architecture.

~500 users
Scale ceiling
~5,000 reviews/day (estimated, not load-tested)
₹0–50/mo
Running cost
within free tiers at current usage
CI + ~19 tests
Quality gates
lint, typecheck, Vitest, Playwright, rules suite

Known Limitations & What's Next

▲Honest caveats

No latency, bundle, or coverage numbers here are independently benchmarked — the sub-50ms search and the scale ceiling are targets and estimates from the engineering docs, not load-test results. The write pipeline has no queue, so side effects share the serverless request window. A few denormalized fields (userName, userLevel, avatarUrl) go stale with no backfill job, and helpfulVotedBy is an unbounded array that needs a subcollection before real scale.

Next steps that follow directly from the current design: move review side effects to a real queue (Cloud Tasks / QStash) so the request returns immediately and partial failures retry; migrate helpfulVotedBy to a subcollection; add a backfill job for denormalized user fields; and put the search and scale claims behind actual benchmarks.

Lessons Learned

Denormalization is a write-time contract, not a schema shortcut

The moment you copy avgOverall onto a dish, every create / edit / delete becomes a transaction that must do exact inverse arithmetic. Budget for that complexity up front — in a NoSQL app, correctness lives in the write path, not the read model.

Graceful degradation only works if each dependency is optional at the type boundary

Making every third-party .optional() in one Zod env schema forced every consumer to handle absence, which is why the app genuinely runs on Firebase alone. Optionality bolted on later never achieves this.

A one-time production warning beats a silent fallback

The in-memory rate-limiter "works" in dev and quietly protects nothing in serverless. Emitting a single Sentry error when Upstash is missing in production turned an invisible security gap into an actionable alert — cheap insurance for fallbacks that lie about being fine.

Docs drift from code fast — trust the code

The README said the wrong framework version, the engineering doc described a middleware file that didn't exist and used an old name for the points currency, and CI used a different package manager than the one declared. Detailed docs are valuable, but they're claims to verify, not ground truth.

Tech Stack: Next.js 16 · React 19 · TypeScript (strict) · Cloud Firestore · Firebase Auth · Typesense · Upstash Redis · Zustand · TanStack Query · Zod · Tailwind CSS v4 · Razorpay · Cloudinary · Resend · Sentry · Serwist · Vercel

NextCaloVoice: Voice-First Calorie Tracker

Built with intention, not templates. If you made it this far, thank you for reading thoughtfully.

© 2026 · Designed and built with care