TL;DR: VentureDen is a startup pitch platform where founders sign in with GitHub, submit a pitch, and get it scored by Gemini 2.5 Flash. It is a pnpm and Turborepo monorepo with a Next.js 16 frontend and a Sanity Studio that share generated TypeScript types. The hard part was making model output dependable, using a response schema, Zod validation, a 10-second timeout, and a pitch that still publishes when the analysis fails.
01
Next.js 16 with auth, user content and a CMS
I wanted to know how Next.js 16, React 19, the React Compiler and Turbopack behave together once authentication, user-generated content and a CMS are all in the same app. Release notes don't answer that. A toy demo wouldn't either, so I needed a product with all three.
A startup pitch platform had the right shape. Founders submit pitches, other people upvote and comment, and every pitch gets scored by an LLM. That last part gave me a reason to build a structured model pipeline next to a CMS-managed marketing site, inside one monorepo.

02
Two apps and five packages in one workspace
The repo is a pnpm and Turborepo monorepo. apps/web is the Next.js frontend and apps/studio is the Sanity Studio. Everything they share lives in packages/:
A new pitch takes this path. A founder signs in with GitHub OAuth (NextAuth v5); the first sign-in creates their author document, which becomes their public profile. They write the pitch in a multi-step form with a Tiptap/Novel rich-text editor. The createPitch server action converts the editor's HTML to Markdown with Turndown, validates the fields with Zod, uploads the cover image to Sanity, creates the startup document, and then asks Gemini for an analysis.
The marketing surface (homepage hero, logo ticker, top pitches, integrations, FAQ) is a typed Sanity page builder with click-to-edit visual editing and live preview, wired through GROQ and Sanity's live content API. Browsing adds category filters, sorting by recent, upvotes or views, and client-side fuzzy search with Fuse.js over title, category, author name and description.

03
Gemini as a typed API with a 10-second budget
Instead of dumping the model's prose on the screen, I treated Gemini as a data source with a contract.
The call to gemini-2.5-flash sets responseMimeType: "application/json" and a responseSchema with four required keys: clarity, marketPositioning and uniqueness (each an integer score plus feedback text) and suggestions. The system prompt holds the rubric: each dimension is scored 0 to 100, the meaning of 80+ and below 40 is spelled out per dimension, and the model must return two or three suggestions that point at specific parts of the pitch.
A response schema tells the model what to return; it doesn't promise it will. So the parsed JSON goes through a stricter Zod schema: scores must be integers from 0 to 100, feedback 1 to 1,000 characters, each suggestion at most 800 characters, and two or three suggestions in total. Only after that does my code compute the overall score:
const overallScore = Math.round(
clarity.score * 0.35 + marketPositioning.score * 0.35 + uniqueness.score * 0.3
);Doing the weighting in code means the model can't get the arithmetic wrong, and changing the weights never touches the prompt.
Two limits keep the call bounded. The pitch body is cut to 5,000 characters before it goes into the prompt, and the request has a 10-second timeout. Any failure (timeout, invalid JSON, a Zod rejection) is logged and returns null.
That null sets the order of operations. The pitch document is created first, and the analysis is patched onto it as aiAnalysis only if one came back. If Gemini is down, the founder loses the score panel and the pitch still publishes. On the detail page each dimension renders as its own scored panel, so a redesign only touches components that read typed fields; no code parses paragraphs.

04
A 2,857-line type file shared by two apps
The Studio owns the schema and the frontend owns the queries. Running sanity typegen generate in the Studio writes packages/sanity/src/sanity.types.ts, currently 2,857 lines. The 38 GROQ queries in packages/sanity/src/query.ts are declared with defineQuery, so each query's result type comes from the schema. The web app imports both from the shared sanity package.
In practice: rename a field in the Studio, regenerate, and every query and component that reads it fails check-types in the web app until it's fixed. Turborepo runs lint, format and check-types across both apps and caches the results, so an unchanged package doesn't get checked twice.
Counters live on the same documents. A pitch view is counted by a server component that calls writeClient.patch(id).inc({ views: 1 }) inside Next's after(), so the increment runs after the response and never blocks render. Upvotes use the same atomic inc from a POST route at /api/startups/[id]/upvote. Because content is fetched through Sanity's live API, the feed and homepage pick up newly published content without a redeploy.
05
Trade-offs
| Chose | Over | Why | Cost |
|---|---|---|---|
| Sanity with GROQ | A custom Prisma and Postgres layer | Editor-managed content, a visual page builder in the Studio, live queries that publish without a redeploy | Upvotes, views and comments are document writes through the Sanity write client, not database rows |
Gemini responseSchema plus Zod | Free-form model text | Typed fields render as panels and can be rejected when malformed | Two schemas describe the same shape and have to be kept in sync by hand |
Running the analysis inside createPitch | Running it after the response | The score already exists when the founder opens their pitch | Submitting waits on Gemini, up to the 10-second timeout |
| pnpm and Turborepo monorepo | Separate repos for site and Studio | Generated Sanity types flow straight into the frontend | Upfront wiring of Turborepo and the five shared packages |
| Client-side Fuse.js search | A search endpoint | Results update on every keystroke with no request | It only searches the startups already passed to the page |
| Next.js 16, React 19 and the React Compiler | Older, better-documented versions | Learning how they behave together was the point | Sparse docs, edge cases solved by reading source and GitHub discussions, and auth on next-auth 5.0.0-beta.30 |
06
Results
- VentureDen is live at venture-den.aayushbharti.in (opens in new tab), with the source on GitHub (opens in new tab).
- Every submitted pitch makes one Gemini call with a 10-second ceiling, and the page receives either a Zod-validated analysis or none.
- The 38 GROQ queries are typed against a 2,857-line generated file, so Studio schema changes surface as type errors in the frontend.
- The web app builds with
reactCompiler: trueand the dev server runs on Turbopack.
07
What I'd change
The upvote route is the weakest part of the app. It increments the count by one with no session check and no record of who voted, so anyone can POST to it repeatedly and inflate a pitch. I'd require sign-in and store one vote per user per pitch, counting from those records instead of a bare counter.
I'd also take Gemini out of the submit path. Views already use after() to do work once the response is sent; the analysis could do the same, with the detail page showing a pending state until aiAnalysis lands. Founders would stop waiting on the model after they press submit.
