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.
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.
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.
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.
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.
A quality-gated rewards economy
A points incentive invites spam. Points have to reward genuine, substantive reviews and resist farming.
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.
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
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.)
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)
helpfulVotedByis an unbounded array that will hurt at scale- Denormalized
userName/userLevel/avatarUrlon 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.
Known Limitations & What's Next
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
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.
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.
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.
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