Two workspaces, one private dataset, and a search pipeline where the language model proposes and the database decides.
By Vortex team · 3 min read
Vortex is a Next.js application in front of a private Sanity dataset, with authentication by Clerk and analytics by PostHog. This note walks through how the pieces fit, and why each boundary sits where it does.
Two workspaces
The repository holds two standalone projects. The Studio is a Sanity Studio: schema and content authoring, nothing else. The web workspace is the Next.js site: pages, the search route, and every server-side integration. Keeping them apart means the Studio can be deployed and upgraded on its own, and TypeGen can generate types for the web app straight from the schema and the GROQ queries.
The browser only talks to the Next.js server. Every credential lives on the server, which talks to Sanity, Clerk, the language model, the YouTube Data API and PostHog.
Content is read-only, state is not
Courses, lessons, categories and video documents are content. Pages render them through one server-only fetch helper that reads the private dataset with a token and caches by tag. None of that is writable from the site.
Learner state is different: progress, enrollments, quiz attempts, plans, ideas and votes. It is keyed to the Clerk user id and written only by server routes that hold a separate write token. The browser never writes anything directly.
Search: the model proposes, the dataset decides
Search is the heart of Vortex, and the place where a language model could most easily go wrong. The route runs two passes at once.
A model pass. The model connects to the Sanity Context MCP server, which exposes the schema and a GROQ query tool scoped by a filter to content types only. It writes queries, reads what comes back, and returns structured hits: a lesson id, a rank, a reason and, for a video moment, the second it starts.
A keyword and chapter pass. A deterministic query matches the words of the question against lesson titles, key points and notes, and against the chapter labels and transcript chunks of each video.
Both passes run in parallel and are merged to one hit per lesson. Grounding then rebuilds every card from the dataset.
The model pass is time-boxed. If it is slow or over its token budget, the keyword results ship on their own rather than the learner waiting on a spinner.
Grounding
The model contributes ids and seconds, never display text. Before anything reaches the browser, grounding re-reads each lesson from Sanity by id: the title, the course, the module and lesson label, the duration and the thumbnail. A hit whose id does not resolve is dropped. A "video" hit without a real start second is downgraded to a lesson hit, because a moment with no timestamp has nothing to link to. Sorting by relevance, duration or recency happens afterwards, in code, so re-sorting never costs another model call.
Video intelligence, built offline
Timestamps come from video documents built by offline tooling, one per unique video. Each holds the video's chapters as a table of contents and its captions split into short timestamped chunks. Search matches chapters first, because chapter labels are clean, and falls back to transcript chunks only when no chapter matches.
Ingestion never runs in the request path. At query time, search fetches only the few chunks that match, never a whole transcript.
Personal plans
Plans are the one feature that reaches outside the catalog at request time. When a signed-in learner describes a goal, the server asks the model for a syllabus, then looks up a video for each lesson through the official YouTube Data API with safe search set to strict and only embeddable videos allowed. Each learner can create a limited number of plans per day, counted from the dataset. A lesson's quiz is generated from that video's captions the first time it is opened, and cached on the plan.
Why these boundaries
The browser holds no secret, so there is nothing to leak from it.
Every write has one door, a server route, so it can be authenticated, validated and rate limited in one place.
The language model has read-only, scoped access and no path to the page except through grounding.