Guide
A For You Page for Your Website
Build it in three steps with a coding agent: record what visitors read, score each option with Jev, then reorder safely. Copy a prompt per step.
You’re building one thing: a list on your site that reorders itself around what the visitor has actually read. Nothing gets hidden, nothing gets removed — order is the only thing that changes.
Three steps, each with a prompt to paste into your coding agent, what you should see afterwards, and how to test it. Build them in order; each one works on its own.
Every API detail below is checked against the AI SDK’s own type definitions and Vercel’s evaluation docs. Where a name is experimental, it says so.
Prerequisites
- AI SDK 7 or later. Evaluation does not exist before it.
- A server you control — a Next.js route handler, or any backend. The model call must never run in the browser.
- A Vercel AI Gateway key, or a linked Vercel project using OIDC.
- A list worth reordering, with a sensible default order that already works. That default order is your fallback, so build it first.
- Content you can label. You need to mark items with their topics yourself; nothing here reads arbitrary page text.
Evaluation is available through the AI SDK, the HTTP API and the TypeSafe-compatible API. Vercel’s docs state it is not supported on the OpenAI-, Anthropic- or Cohere-compatible endpoints, so don’t assume an existing client just works.
Environment variables
Server-side only. Never prefix these with NEXT_PUBLIC_ — that ships your key to the browser.
# .env.local # Vercel AI Gateway credentials. # Or run `vercel link` and `vercel env pull` to use an OIDC token instead. AI_GATEWAY_API_KEY=your_gateway_key_here # Your own switch. Keep the feature off until you choose to turn it on, # and have the route refuse to run when it is unset. RANKING_ENABLED=false
Step 1 — Record what people read
A small client-side journal. You mark your own items with their topics, watch which ones are genuinely on screen, and keep a short list in the tab. No identity, no URLs, no page text.
Build a client-side interest journal for this project. Read the code first
and tell me which list we'll eventually rank, and where its current order
comes from, before you write anything.
Then build:
1. Authored markers only. I'll tag my own items with data attributes for
topics, kind and title. Never read form fields, input values or arbitrary
page text.
2. A timer that samples every 2 seconds and does nothing when the tab is
hidden, or after 60 seconds with no pointer, scroll or key activity.
3. Count an item as "on screen" only when a real part of it is visible —
at least half the element, or 40% of the viewport — and confirm nothing
is covering it before crediting time.
4. Two records in sessionStorage, under one key:
- Topic scores: a first-sight award, then a small per-tick award that
stops after about 25 seconds so one idle tab can't dominate. Split each
award across the topics the item matched, and cap any single topic.
- The last 20 items viewed: kind, title, labels, seconds on screen.
Cap seconds per item. A revisit updates the existing entry.
5. A time limit: expire the whole record after 30 minutes, and decay scores
on a half-life so old interest fades instead of sticking.
6. An exported reset() that clears everything, and a way for me to read the
current state for debugging.
Use my own numbers as defaults but keep every threshold a named constant at
the top of the file. Do not add any network calls in this step.What to expect: one storage key holding topic scores and a short list of viewed items. Nothing leaves the browser yet.
How to test it: open the site, read one item for ten seconds, then check the key in DevTools → Application → Session Storage. You should see that item with roughly the right seconds. Scroll past something quickly and confirm it earns little or nothing. Leave the tab in the background for a minute and confirm the numbers stop moving. Open a new tab and confirm it starts empty.
Keep the distinction clear in your own head: seconds on screen is an observation, a topic score is an inference you chose to draw from it. Only the first is a fact.
Step 2 — Score each option with Jev
Jev is an evaluation model: you hand it state and typed questions, and it returns structured answers instead of prose. For ranking, the useful type is score — you supply an ordered array of labels from lowest to highest, and get back an interpolated number plus the probability of each rung. Five labels means a score from 0 to 4.
Questions are answered in parallel in a single request, so one item per question is fine. This is the verified call shape:
import { experimental_evaluate as evaluate } from 'ai'
const { answers } = await evaluate({
model: 'typesafe-ai/jev',
state: {
site: 'One line about what your site is and who is browsing.',
viewed: [{ type: 'Article', title: '…', about: '…', secondsOnScreen: 40 }],
viewedOrder: 'oldest first, most recent last',
},
questions: {
// One per item you want ranked. Keys must be safe identifiers.
some_item_id: {
type: 'score',
instructions: 'How well does this item suit this visitor as a next step?',
criteria: [
'no fit: unrelated to anything they viewed',
'weak fit: only loosely related',
'possible fit: related to some of it',
'good fit: matches a clear interest',
'strong fit: directly matches what they spent the most time on',
],
},
},
maxRetries: 0,
abortSignal: AbortSignal.timeout(3000),
providerOptions: { gateway: { zeroDataRetention: true } },
})
// answers.some_item_id -> { type: 'score', score: 2.97, probabilities: {…} }Add a server route that ranks my list with an evaluation model. Before writing code, check the current Vercel AI Gateway evaluation docs and the installed AI SDK's type definitions, and tell me if anything below has changed. Do not invent SDK methods or options — if something isn't in the docs or the types, say so instead of guessing. Requirements: 1. Server-side only. The gateway credential must never reach the browser. 2. Gate it behind my environment switch. When the switch is off, return 404 and make no model call at all. 3. Accept the journal from step 1 as the request body, and validate it defensively: drop anything of the wrong shape, cap the number of items, cap string lengths, clamp the seconds, and reject known-bad input with a 400. Treat the body as untrusted. 4. Build the state from the visitor's viewed items. Send only titles and labels of my own content — no identity, URLs, page text or form data. 5. One score question per item in my list, using ordered fit-level criteria from lowest to highest. Give each question a stable key derived from the item id, and use the item's own description in the instructions. 6. Set maxRetries to 0 and a short abort timeout of a few seconds. Request zero data retention through providerOptions. 7. Sort by score, descending. Break ties by keeping my existing default order. Return the ranked ids, the raw scores and how long the call took. 8. Never let this route throw to the client. On any failure, log server-side and return an error status with a generic message. Only expose the underlying cause in development. Finish by telling me the exact model id and import you used, and where you confirmed them.
What to expect: a route that returns ranked ids and scores in a few hundred milliseconds, 404s while the switch is off, and never throws.
How to test it: with the switch off, POST to it and confirm a 404. Turn it on and POST a hand-written journal with one obviously relevant item — the matching option should score highest. POST rubbish and confirm a 400 rather than a crash. Unset the gateway key and confirm you get a clean error, not a stack trace. Then check the scores are actually responding to the input: swap the viewed item for something unrelated and confirm the winner changes.
Those scores are relevance grades, not conversion probabilities. A 4 means it matches what someone spent time on. It says nothing about whether they’ll buy. Evaluation requests are billed per token like any other model.
Step 3 — Reorder without breaking anything
The riskiest step, because this is the one your visitors see. The rule that matters: ranking is an enhancement, never a dependency. If it’s slow, off, failed or nonsense, the page renders exactly as it does today.
Wire the ranking from step 2 into the list, as an enhancement that can always be switched off. 1. Rank in the background, not when the list is rendered. Trigger a re-rank when a new item is viewed, or when an item gains another full minute of attention. Debounce it, enforce a minimum interval between calls, and never run two at once. 2. Cache the latest ranking in sessionStorage next to the journal, and clear it on reset. 3. Apply a new order only as the list is approached — never while it is on screen. Cards must not rearrange under someone's eyes. 4. Once the visitor interacts with the list — scrolls it, drags it, picks something, or filters — pin the current order until reload. Filtering hands ordering back to my default logic entirely. 5. Fall back to the existing default order whenever the ranking is missing, stale, still loading, failed, or returns ids that don't match my list. Rendering must never wait on the ranking. 6. Keep a route that browses everything in the default order. Ranking changes what comes first, not what exists. Show me every place the default order is used, so I can confirm the fallback path is real.
What to expect: the list quietly reorders between visits to it, and behaves exactly as before whenever ranking is unavailable.
How to test it: turn the switch off and confirm the page is unchanged and makes no requests. Turn it on, read something specific, scroll to the list, and confirm the related item leads. Block the route in DevTools and confirm the default order still renders with no visible error. Throttle to slow 3G and confirm nothing waits on it. Watch the list while a ranking lands and confirm nothing jumps. Then interact with it and confirm the order stops changing.
Before you ship it
- The feature is off by default, and off means zero requests.
- The gateway key is server-side, and absent from the browser bundle. Search your built JS for it.
- Every failure path lands on your default order — test them, don’t assume them.
- The state you send contains nothing you’d mind reading aloud: no identity, URLs, page text or form input.
- You can explain what a score means before you put one in front of anyone.
- `experimental_evaluate` is experimental. Pin your AI SDK version and expect the name to change.
Useful links
- Vercel — evaluation docsThe three question types, the exact call shape, and provider options. Start here.
- Jev on the Vercel AI GatewayThe model itself, and its per-token pricing.
- Vercel — classify, route and score with Jev and the AI SDKWorked examples beyond ranking.
- AI SDK documentation
Watch what people actually do. Let a model judge fit. Keep the ordering — and the failure — in your own code.