
Create Agent With Sanity Context
- 38 installs
- 4 repo stars
- Updated August 2, 2026
- sanity-io/context
Helps with ai & agent building tasks during AI-assisted development.
About
create-agent-with-sanity-context is a Claude Code skill in the AI & Agent Building category.
- create-agent-with-sanity-context
- AI & Agent Building
- AI-coding skill
Create Agent With Sanity Context by the numbers
- 38 all-time installs (skills.sh)
- +2 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Ranked #8,364 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sanity-io/context --skill create-agent-with-sanity-contextAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 38 |
|---|---|
| repo stars | ★ 4 |
| Last updated | August 2, 2026 |
| Repository | sanity-io/context ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Build an Agent with Sanity Context
Give AI agents intelligent access to your Sanity content. Unlike embedding-only approaches, Sanity Context is schema-aware—agents can reason over your content structure, query with real field values, follow references, and combine structural filters with semantic search.
What this enables:
- Agents understand the relationships between your content types
- Queries use actual schema fields, not just text similarity
- Results respect your content model (categories, tags, references)
- Semantic search is available when needed, layered on structure
Sanity Context gives agents your schema and teaches them GROQ, but it can't know your domain. You close that gap through the Instructions field (dataset-specific query guidance) and optionally the system prompt (agent behavior and tone).
Three actors in this workflow:
- You — the agent executing this skill, helping the user set things up
- The user — the human you're working with, who knows their domain and data
- The production agent — the agent being built, which will serve end users
What You'll Need
Before starting, gather these credentials:
| Credential | Where to get it |
|---|---|
| Sanity Project ID | Your sanity.config.ts or sanity.io/manage |
| Dataset name | Usually production — check your sanity.config.ts |
| Sanity API read token | Run npx sanity tokens add "Sanity Context" --role=viewer --yes --json from the project directory (or pass --project-id=<id>). Alternatively, create at sanity.io/manage → Project → API → Tokens with Viewer role. |
| LLM API key | From your LLM provider (Anthropic, OpenAI, etc.) — any provider works |
How Sanity Context Works
The Sanity Context MCP server gives AI agents structured access to Sanity content. The core integration pattern:
1. Initial Context: Fetch schema context via the /initial-context HTTP endpoint and inject it into the system prompt 2. MCP Connection: HTTP transport to the Sanity Context URL 3. Authentication: Bearer token using Sanity API read token 4. Tool Discovery: Get available tools from MCP client, pass to LLM 5. System Prompt: Tell the production agent its role, tone, and boundaries
MCP URL formats:
https://api.sanity.io/v2026-03-03/context/mcp/:projectId/:dataset— Base URL. No document needed, configure via query params or use as-is.https://api.sanity.io/v2026-03-03/context/mcp/:projectId/:dataset/:slug— Document URL. Applies the configuration from a Sanity Context document.
Sanity Context documents (type sanity.agentContext) are created in Sanity Studio and configure the MCP endpoint. They have three fields:
| Field | Schema field | Purpose |
|---|---|---|
| Slug | slug | Unique URL identifier — becomes the :slug in the MCP URL |
| Instructions | instructions | Domain-specific guidance for the agent, injected into tool descriptions |
| Content Filter | groqFilter | A GROQ expression scoping which documents the agent can access |
This means Studio users can manage agent behavior without touching code — updating instructions or narrowing the content filter takes effect immediately.
URL query params override the document's configuration (useful for testing and development):
?instructions=<content>— Override instructions (use?instructions=""for a blank slate)?groqFilter=<expression>— Override the content filter
The integration is simple: Connect to the MCP URL, get tools, use them. The reference implementation shows one way to do this—adapt to your stack and LLM provider.
Initial context (recommended):
Always fetch the schema context via the /initial-context HTTP endpoint and inject it into the system prompt. This gives a significant latency improvement on the first message—the agent already knows the schema and available tools without needing a tool call. It also enables better prompt caching since the schema prefix is stable across conversations.
Append /initial-context to the MCP URL path (before any query params), using the same auth header:
curl https://api.sanity.io/v2026-03-03/context/mcp/:projectId/:dataset/:slug/initial-context \
-H "Authorization: Bearer $SANITY_API_READ_TOKEN"Fetch once, cache the result, and include it in your system prompt. When using this, exclude the initial_context tool from the tools passed to the LLM to avoid redundant calls.
If you don't control the system prompt (e.g. using a third-party MCP client), the initial_context MCP tool still works — the agent will call it on the first message instead.
Available MCP Tools
| Tool | Purpose |
|---|---|
initial_context | Get compressed schema overview (types, fields, document counts). Also available via the /initial-context HTTP endpoint. |
groq_query | Execute GROQ queries with optional semantic search |
schema_explorer | Get detailed schema for a specific document type |
For development and debugging: The general Sanity MCP provides broader access to your Sanity project (schema deployment, document management, etc.). Useful during development but not intended for customer-facing applications.
Before You Start: Understand the User's Situation
A complete integration has four distinct components that may live in different places:
| Component | What it is | Examples |
|---|---|---|
| 1. Studio Setup | Configure the context plugin and create Sanity Context documents | Sanity Studio (separate repo or embedded) |
| 2. Agent Implementation | Code that connects to Sanity Context and handles LLM interactions | Next.js API route, Express server, Python service, or any MCP-compatible client |
| 3. Frontend | UI for users to interact with the agent | Chat widget, search interface, CLI—or none for backend services |
| 4. Functions | Scheduled classification via Sanity Blueprints | sanity.blueprint.ts + functions/ directory — has its own placement constraints (see Sanity Blueprints & Functions) |
A deployed Studio (v5.1.0+) is always required. Not every integration needs the Sanity Context plugin or document—the base MCP URL works without them, so users can start with just agent implementation and add document configuration later—or vice versa. Frontend depends on the use case (many agents run as backend services or integrate into existing UIs).
Before writing any code, inspect the project to understand:
1. Project layout: Read the top-level package.json (check for workspaces or a pnpm-workspace.yaml), locate the lockfile, and map out the distinct apps/packages. This determines where sanity.blueprint.ts and functions/ will go — see Sanity Blueprints & Functions. 2. Their stack: What framework/runtime? (Next.js, Remix, Node server, Python, etc.) 3. Their AI library: Vercel AI SDK, LangChain, direct API calls, etc. 4. Their domain: What will the agent help with? (Shopping, docs, support, search, etc.) 5. Which components they need help with: They may only need one or two.
- Components in different repos (most common): You may only have access to one component. Complete what you can, then tell the user what steps remain for the other repos.
- Co-located components: All in the same project—work through them based on what the user wants to tackle first.
- No Studio in the codebase? Ask the user if Studio setup is done elsewhere, or if they want to skip the Sanity Context plugin and document for now—the base URL works without them.
The reference patterns use Next.js + Vercel AI SDK, but adapt to whatever the user is working with.
Workflow
Always present the full workflow. Even if the user's request seems narrow, inform them of all four steps — you don't have to implement everything, but they should know what's available. A working chatbot without Insights is only half the value. Walk the user through all four steps, explaining what each unlocks:
1. Build the Agent — Get a working chatbot connected to their content 2. Studio Setup — Configure the plugin and create a Sanity Context document 3. Conversation Insights — Track and classify conversations (this is what makes the data useful) 4. Tune the Agent — Refine instructions and system prompt using the tuning skills
After completing each step, proactively present the next one. Only stop when all steps are done or the user explicitly defers.
Quick Validation (Optional)
Before building the production agent, validate that the MCP endpoint is reachable. If the user doesn't have a read token yet, offer to create one from the terminal — detect the projectId from sanity.config.ts or sanity.cli.ts if available:
npx sanity tokens add "Sanity Context" --role=viewer --yes --jsonThis outputs JSON with the token value. If not inside a Sanity project directory, pass --project-id=<id> explicitly.
Then test the endpoint:
curl -X POST https://api.sanity.io/v2026-03-03/context/mcp/:projectId/:dataset \
-H "Authorization: Bearer $SANITY_API_READ_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'This confirms the token works and the endpoint is reachable. The base URL (no slug) works without a Sanity Context document—add a slug to apply a document's configuration.
Step 1: Build the Agent (Adapt to user's stack)
The user already has an agent or MCP client? They just need to connect it to their Sanity Context URL with a Bearer token. The tools will appear automatically.
Building from scratch? Help the user set up the MCP connection and LLM integration. The reference implementations use Vercel AI SDK with Anthropic, but the pattern works with any LLM provider (OpenAI, local models, etc.). Start with the basics and add advanced patterns as needed.
Framework-specific guides:
- Next.js: See references/nextjs-agent.md
- SvelteKit: See references/sveltekit-agent.md
- Other stacks (Express, Remix, Python, LangChain): See references/adapting-to-stacks.md
System prompts (applies to all frameworks): See references/system-prompts.md for structure and domain-specific examples (e-commerce, docs, support, content curation).
The framework guides cover:
- Core setup (required): MCP connection, authentication, basic chat route
- Frontend (optional): Chat component for the framework, including markdown rendering (LLM responses are markdown — a renderer like
react-markdownormarkedis needed to display formatted output) - Advanced patterns (optional): Client-side tools, auto-continuation, custom directive rendering
Step 2: Set up Sanity Studio
Help the user configure the @sanity/context/studio plugin in their Studio and create a Sanity Context document. This document controls what the production agent can see (via groqFilter) and what guidance it receives (via instructions).
See references/studio-setup.md
Step 3: Conversation Insights (Recommended)
Recommend the user sets up Insights. Without tracking, there's no way to know if the agent is actually helping users or failing silently. Insights shows you what users ask, where the agent struggles, and what content is missing — data you need to improve the agent over time.
What this unlocks:
- See which conversations succeed and which fail
- Discover content gaps — topics users ask about that the agent can't answer well
- Debug specific conversations with full transcripts
- Compare performance across multiple agents
Setup is two parts — do both:
1. Telemetry — Add one integration to your existing streamText call (saves conversations) 2. Classification — Deploy a scheduled function that analyzes conversations with AI
Telemetry without classification just stores raw conversations. Classification is what extracts success scores, sentiment, and content gaps — the actual insights. Always set up both.
Follow [references/conversation-classification.md](references/conversation-classification.md) to set this up. The guide covers both parts end-to-end. The dashboard appears in Studio automatically once deployed.
Step 4: Tune Your Agent (Recommended)
Once the production agent works:
1. Tune the Instructions field using the dial-your-context skill — an interactive session where you explore the user's dataset together, verify findings, and produce concise Instructions that teach the production agent what the schema alone doesn't make obvious: counter-intuitive field names, second-order reference chains, data quality issues, required filters, and query patterns. The skill can also help configure a groqFilter to scope what content the production agent sees.
2. Shape the system prompt (optional) using the shape-your-agent skill — if the user controls the production agent's system prompt, this helps define tone, boundaries, and guardrails. Skip this if the user doesn't control the system prompt.
Sanity Blueprints & Functions
Scheduled classification uses Sanity Blueprints to deploy Sanity Functions.
Placement principles
Before adding files, search the project for an existing sanity.blueprint.ts. If one exists with deployed functions, add the new function there — even if it's not next to the lockfile. An existing working setup takes precedence over the default placement rules below. Only follow these rules when creating a new blueprint from scratch.
Find the project's lockfile (yarn.lock, pnpm-lock.yaml, or package-lock.json). Two rules for new blueprints:
1. `sanity.blueprint.ts` must be in the same directory as the lockfile. The CLI detects the package manager from the lockfile. If no lockfile is present, pass --fn-installer pnpm (or npm/yarn) to the deploy command. 2. Function `src` paths are resolved relative to the blueprint file. By default a function named classify-conversations maps to functions/classify-conversations/ next to the blueprint. Use the src property in defineScheduledFunction to point to a different directory.
In a monorepo with no existing blueprint, the lockfile is at the workspace root — so sanity.blueprint.ts and functions/ go there too, alongside the root package.json. However, if a blueprint already exists in a subdirectory (e.g. apps/studio/) and functions are successfully deploying from there, use that location. The CLI can work from subdirectories when configured correctly (e.g. with --fn-installer pnpm).
Dependencies: Functions use the package.json next to the blueprint for dependencies by default (project-level). Each function can alternatively have its own package.json (function-level), but a function uses one or the other — never both. See Sanity Functions: Dependencies.
Commands
Run from the directory containing sanity.blueprint.ts:
| Command | Purpose |
|---|---|
npx sanity blueprints init | Initialize the blueprint stack (first time only) |
npx sanity blueprints promote | Promote to org scope (required for scheduled functions) |
npx sanity blueprints doctor | Check blueprint health and flag issues |
npx sanity blueprints plan | Preview what deploy will change |
npx sanity blueprints deploy | Deploy blueprint and functions |
npx sanity functions env add <fn> <key> <value> | Set an env var (after deploy) |
npx sanity functions logs <name> | View function logs |
npx sanity functions test <name> --with-user-token | Test function locally |
GROQ with Semantic Search
Sanity Context supports text::semanticSimilarity() for semantic ranking:
*[_type == "article" && category == "guides"]
| score(text::semanticSimilarity("getting started tutorial"))
| order(_score desc)
{ _id, title, summary }[0...10]Always use order(_score desc) when using score() to get best matches first.
Adapting to Different Stacks
The MCP connection pattern is framework and LLM-agnostic. Whether Next.js, Remix, Express, or Python FastAPI—the HTTP transport works the same. Any LLM provider that supports tool calling will work.
See references/adapting-to-stacks.md for:
- Framework-specific route patterns (Express, Remix, Python)
- AI library integrations (LangChain, direct API calls)
See references/system-prompts.md for domain-specific examples (e-commerce, docs, support, content curation).
Best Practices
- Start simple: Build the basic integration first, then add advanced patterns as needed
- Schema design: Use descriptive field names—agents rely on schema understanding
- GROQ queries: Always include
_idin projections so agents can reference documents - Content filters: Use
groqFilterto scope what the production agent sees — start broad, then narrow based on what it actually needs. The filter is a full GROQ expression (e.g.,_type in ["product", "article"]) - Instructions field: Keep it concise — only include what the auto-generated schema doesn't make obvious. Don't duplicate schema information. See the
dial-your-contextskill. - System prompts: Be explicit about forbidden behaviors and formatting rules. Less is more — an over-engineered prompt can interfere with the Instructions content. See the
shape-your-agentskill. - Package versions: Always use the latest version of
@sanity/context— runnpm info @sanity/context versionto get it. For other packages, check the referencepackage.jsonfiles or usenpm info <package> version. AI SDK and Sanity packages update frequently, and using outdated versions will cause errors that are hard to debug.
Troubleshooting
Sanity Context returns errors or no schema
Sanity Context requires a deployed Studio. See Deploy Your Studio for instructions.
"401 Unauthorized" from MCP
The SANITY_API_READ_TOKEN is missing or invalid. Generate a new token from the terminal:
npx sanity tokens add "Sanity Context" --role=viewer --yes --jsonOr create one at sanity.io/manage → Project → API → Tokens with Viewer role.
"No documents found" / Empty results
Check the Sanity Context document's content filter (groqFilter):
- Is the GROQ filter correct?
- Are the document types spelled correctly?
- Are there published documents matching the filter?
Tools not appearing
1. Check that mcpClient.tools() returns tools (log it) 2. Ensure the MCP URL is correct (project ID, dataset, and optionally slug) 3. If using a slug-based URL, verify the Sanity Context document is published
Adapting to Different Stacks
The MCP connection pattern is framework and LLM-agnostic. This guide shows how to adapt the core pattern to different frameworks and AI libraries.
Contents
---
The Universal Pattern
Regardless of framework, the integration follows this flow:
1. Fetch initial context via HTTP (${MCP_URL}/initial-context) — cache the result
2. Create MCP client with HTTP transport
3. Authenticate with Sanity API token
4. Get tools from MCP client
5. Build system prompt with initial context injected
6. Pass tools to your LLM along with system prompt
7. Handle tool calls and responses
8. Clean up MCP connection when done---
Initial Context via HTTP
Append /initial-context to the MCP URL path (before any query params) to fetch the schema context as plain HTTP. Same auth header, same query params. Cache the result and inject it into your system prompt:
const url = new URL(MCP_URL)
url.pathname = `${url.pathname.replace(/\/$/, '')}/initial-context`
const response = await fetch(url, {
headers: { Authorization: `Bearer ${API_TOKEN}` },
})
const initialContext = await response.text()
const systemPrompt = `${BASE_PROMPT}\n\n# Content context\n\n${initialContext}`This eliminates the initial_context tool call on every first message and enables prompt caching (the schema prefix is stable across conversations).
---
Different Frameworks
Express/Node.js
app.post('/api/chat', async (req, res) => {
const [mcpClient, initialContext] = await Promise.all([
createMCPClient({
transport: {
type: 'http',
url: process.env.SANITY_CONTEXT_MCP_URL,
headers: {Authorization: `Bearer ${process.env.SANITY_API_READ_TOKEN}`},
},
}),
fetchInitialContext(), // See "Initial Context via HTTP" above
])
const tools = await mcpClient.tools()
// Include initialContext in system prompt, pass tools to LLM...
})Remix
export async function action({request}: ActionFunctionArgs) {
const mcpClient = await createMCPClient({
transport: {
type: 'http',
url: process.env.SANITY_CONTEXT_MCP_URL,
headers: {Authorization: `Bearer ${process.env.SANITY_API_READ_TOKEN}`},
},
})
const tools = await mcpClient.tools()
// Pass tools to your LLM, handle response...
}Python/FastAPI
import httpx
from mcp import Client, HttpTransport
# Fetch initial context via HTTP
async def fetch_initial_context() -> str:
from urllib.parse import urlparse, urlunparse
parsed = urlparse(os.environ["SANITY_CONTEXT_MCP_URL"])
url = urlunparse(parsed._replace(path=parsed.path.rstrip("/") + "/initial-context"))
async with httpx.AsyncClient() as http:
resp = await http.get(
url,
headers={"Authorization": f"Bearer {os.environ['SANITY_API_READ_TOKEN']}"},
)
return resp.text
client = Client(
transport=HttpTransport(
url=os.environ["SANITY_CONTEXT_MCP_URL"],
headers={"Authorization": f"Bearer {os.environ['SANITY_API_READ_TOKEN']}"}
)
)
initial_context, tools = await fetch_initial_context(), await client.get_tools()
# Include initial_context in system prompt, pass tools to LLM...---
Different AI Libraries
LangChain: Wrap MCP tools as LangChain tools
const mcpTools = await mcpClient.tools()
const langchainTools = mcpTools.map(
(tool) =>
new DynamicTool({
name: tool.name,
description: tool.description,
func: async (input) => mcpClient.callTool(tool.name, JSON.parse(input)),
}),
)Direct Anthropic API: Pass tool definitions directly
const tools = await mcpClient.tools()
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
system: systemPrompt,
messages,
tools: tools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.inputSchema,
})),
})---
Questions to Ask Users
When adapting this pattern, understand:
1. "What framework are you using?" — Determines route/endpoint structure 2. "What AI SDK or library?" — Determines how tools are passed to the LLM 3. "What's the agent's purpose?" — Shapes the system prompt 4. "What content types will it access?" — Informs the GROQ filter in Studio 5. "Streaming or request/response?" — Affects response handling
Conversation Insights
Track and classify agent conversations using @sanity/context. This enables analytics, debugging, and understanding how users interact with your agent.
Reference Implementation: See ecommerce/\_index.md for file navigation.
Overview
The Insights system has two parts that work together:
1. Telemetry Integration — Saves conversations from your chat route 2. Scheduled Classification — Analyzes conversations with AI to extract insights
Set up both parts. Telemetry alone just stores raw conversations. Classification is what produces the dashboard with success scores, sentiment, and content gaps.
The contextPlugin() includes Insights by default (conversation schema and dashboard). No custom schema needed.
Prerequisites
Before setting up insights, gather:
| Requirement | Where used | Notes |
|---|---|---|
| Sanity Project ID | Both | From sanity.config.ts or sanity.io/manage |
| Dataset name | Both | Usually production |
| Write token | Telemetry (Step 1) | For saving sanity.agentContextConversation documents. Use an existing write token if the project has one, or create one at sanity.io/manage → Project → API → Tokens with Editor role |
| LLM API key | Classification (Step 3) | For the scheduled function that classifies conversations (Anthropic, OpenAI, etc.) |
Note: The classification function uses a robot token (created automatically by the blueprint) — you don't need to create a separate token for it.
Project Structure
First, check if the project already has a `sanity.blueprint.ts` — search the full repo. If one exists with deployed functions, add the classification function there. Do not create a second blueprint.
If no blueprint exists, create one following the placement rules in SKILL.md. The default placement is next to the project's lockfile.
If creating a new blueprint in a monorepo, the default placement is the workspace root (next to the lockfile):
my-monorepo/
├── sanity.blueprint.ts # Next to lockfile
├── functions/
│ └── classify-conversations/
│ └── index.ts
├── package.json # Function deps go here (project-level)
├── yarn.lock # (or pnpm-lock.yaml, package-lock.json)
├── .env
└── apps/
├── studio/
└── web/In a flat project, the layout is the same — everything at the root:
my-project/
├── sanity.blueprint.ts
├── functions/
│ └── classify-conversations/
│ └── index.ts
├── package.json
├── pnpm-lock.yaml
├── .env
├── studio/
└── app/These are reference layouts for new blueprints — always adapt to the user's existing directory structure. If a blueprint already exists elsewhere, use that location instead. If the project has multiple blueprint stacks in a subdirectory pattern (e.g. apps/blueprints/studio/, apps/blueprints/web/), create a new stack following the same convention.
Setup
Step 1: Enable Telemetry in Your Chat Route
Add sanityInsightsIntegration to your streamText call. This saves conversations automatically.
import {sanityInsightsIntegration} from '@sanity/context/ai-sdk'
import {streamText} from 'ai'
const result = streamText({
model: anthropic('claude-sonnet-4-5'),
messages,
experimental_telemetry: {
isEnabled: true,
integrations: [
sanityInsightsIntegration({
client: writeClient, // Sanity client with Editor permissions
agentId: 'my-agent', // Name/ID for grouping conversations
threadId: chatId, // Unique conversation thread ID
}),
],
},
})Write client: Requires a Sanity client with a token that has Editor permissions. Ask the user if they already have a write token in their environment — many projects do (e.g. ADMIN_STUDIO_WRITE_TOKEN, SANITY_API_WRITE_TOKEN). If not, create one at sanity.io/manage → Project → API → Tokens with Editor role.
Thread ID: Each conversation needs a unique threadId. Generate one when a new chat starts and persist it across messages in that conversation. How it reaches the server depends on the setup:
- AI SDK `useChat`: The hook sends
id(the chat ID) in the request body automatically. Extract it in your route handler and use it asthreadId. - Custom transport: Pass the thread ID via request body, headers, or cookies — whatever fits the app's architecture.
See ecommerce/app/src/app/api/chat/route.ts for how this is handled with cookies.
For client-side thread ID generation, use SSR-safe initialization to avoid hydration mismatches:
const [threadId] = useState(() =>
typeof window !== 'undefined' ? crypto.randomUUID() : ''
)Then pass it to your chat API via request body or headers.
Not using AI SDK? The telemetry integration requires Vercel AI SDK. If using another library, use saveConversation directly:
import {saveConversation} from '@sanity/context/insights'
// Call this after each conversation turn completes
await saveConversation({
client: writeClient,
agentId: 'my-agent',
threadId: chatId,
messages: [
{role: 'user', content: 'How do I return an item?'},
{role: 'assistant', content: 'You can return items within 30 days...'},
// Include full conversation history each call — it upserts the document
],
modelProvider: 'anthropic',
modelId: 'claude-sonnet-4-5',
tokenUsage: {inputTokens: 1200, outputTokens: 350, totalTokens: 1550},
})The function generates a deterministic document ID from agentId + threadId, so repeated calls update the same document. See the Insights API Reference below for full API details.
---
Steps 2-7 below set up the classification function — a separate scheduled job that analyzes saved conversations. This runs outside your app using Sanity Functions.
Step 2: Add Dependencies
Ensure these packages are in the package.json next to sanity.blueprint.ts — merge them into existing dependencies, do not overwrite the file:
dependencies: @ai-sdk/anthropic (^3), @sanity/context (latest), @sanity/client (^7), @sanity/functions (^1), ai (^6.0.175 minimum — required for experimental_telemetry.integrations)
devDependencies: @sanity/blueprints (latest), dotenv (^17)
If using a different LLM provider, swap @ai-sdk/anthropic for your provider's package (e.g., @ai-sdk/openai).
Step 3: Create the Classification Function
Create functions/classify-conversations/index.ts next to sanity.blueprint.ts:
// functions/classify-conversations/index.ts
import {createClient} from '@sanity/client'
import {classifyConversations} from '@sanity/context/insights'
import {scheduledEventHandler} from '@sanity/functions'
import {anthropic} from '@ai-sdk/anthropic'
export const handler = scheduledEventHandler(async ({context}) => {
if (!context.clientOptions?.token) {
console.error('[classify-conversations] No client token available')
return
}
// SANITY_PROJECT_ID and SANITY_DATASET are injected by the blueprint's env block.
// These are example names — adapt to match the user's env var conventions.
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
apiVersion: '2026-01-01',
token: context.clientOptions.token,
useCdn: false,
})
const result = await classifyConversations({
client,
model: anthropic('claude-sonnet-4-5'),
telemetry: {
shareMetrics: true,
// shareConversations: true,
// contact: 'you@company.com',
},
})
console.log(
`Classified ${result.successCount}/${result.totalFound} conversations${result.errorCount > 0 ? ` (${result.errorCount} failed)` : ''}`,
)
})Step 4: Configure the Blueprint
If sanity.blueprint.ts already exists, add the scheduled function and robot token resources to it. Otherwise, create it:
// sanity.blueprint.ts
import {defineBlueprint, defineRobotToken, defineScheduledFunction} from '@sanity/blueprints'
import 'dotenv/config'
export default defineBlueprint({
resources: [
defineScheduledFunction({
name: 'classify-conversations',
timeout: 600,
robotToken: '$.resources.classify-conversations-robot.token',
env: {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
SANITY_PROJECT_ID: process.env.SANITY_STUDIO_PROJECT_ID,
SANITY_DATASET: process.env.SANITY_STUDIO_DATASET,
},
event: {
expression: '*/10 * * * *', // Every 10 minutes
},
}),
defineRobotToken({
name: 'classify-conversations-robot',
label: 'Classify Conversations Robot',
memberships: [
{
resourceType: 'project',
resourceId: process.env.SANITY_STUDIO_PROJECT_ID!,
roleNames: ['editor'],
},
],
}),
],
})How this works: The env block reads from your local .env at deploy time and injects the values into the function's process.env at runtime. The robot token provides only the auth token — scheduled functions need projectId and dataset via env vars. The env var names on the left (SANITY_PROJECT_ID) are what the function reads; the names on the right (SANITY_STUDIO_PROJECT_ID) are what your .env file uses. Ask the user for the correct .env var names in their project.
Robot token role: The robot token must have editor role — classification writes results back to conversation documents. Using viewer will cause silent write failures.
Step 5: Configure Environment Variables
The function needs three values at runtime: project ID, dataset, and an LLM API key.
Project ID and dataset are passed via the blueprint's env block (Step 4). The blueprint reads from your .env at deploy time. Create or update .env next to sanity.blueprint.ts — ask the user what env var names their project uses:
# Example — use the env var names from the project's existing .env
SANITY_STUDIO_PROJECT_ID=your-project-id
SANITY_STUDIO_DATASET=production
ANTHROPIC_API_KEY=sk-ant-...LLM API key is also in the env block, so it's read from .env at deploy time. You can alternatively set it after deploying (Step 7) via npx sanity functions env add — useful if you don't want secrets in .env or are deploying from CI.
Step 6: Test Locally
Before deploying, verify the full pipeline works:
1. Conversations are saved: Check Studio for sanity.agentContextConversation documents (send a few messages to your agent first) 2. Insights tool is visible: Open Studio and confirm the Agent Insights tool appears in the topbar 3. Classification runs: Execute the function locally:
npx sanity functions test classify-conversations --with-user-tokenThe --with-user-token flag injects your personal token into context.clientOptions — this is the same path the robot token uses in production. The function reads ANTHROPIC_API_KEY from the .env file next to sanity.blueprint.ts.
Note: Local testing runs against your real dataset — conversations will actually be classified. Only conversations that have been idle for at least 10 minutes are eligible for classification (to avoid classifying active conversations).
Step 7: Deploy
Run all commands from the directory containing sanity.blueprint.ts.
Prerequisites: Make sure you're logged in to the Sanity CLI. Run npx sanity login if needed.
# 1. Install dependencies
pnpm install # or npm install / yarn
# 2. Initialize the blueprint stack (first time only)
npx sanity blueprints init
# 3. Promote to organization scope (required for scheduled functions)
npx sanity blueprints promote
# 4. Check for issues
npx sanity blueprints doctor
# 5. Deploy the blueprint and function (ask for permission to deploy)
npx sanity blueprints deploy
# 6. Set the API key as an environment variable (after deploy)
npx sanity functions env add classify-conversations ANTHROPIC_API_KEY <your-api-key>What these commands do:
- `blueprints init`: Links your project to a Sanity blueprint stack. Run once per project.
- `blueprints promote`: Elevates the stack to organization scope, which is required for scheduled functions. You need organization member permissions to run this.
- `blueprints doctor`: Checks blueprint health — flags dependency issues, version mismatches, and directory structure problems.
- `blueprints deploy`: Deploys the function and schedules it to run.
- `functions env add`: Sets an environment variable for a deployed function. Must be run after deploy. Replace
<your-api-key>with your actual API key.
Step 8: Verify Deployment
# Check function logs
npx sanity functions logs classify-conversations
# Manually trigger for testing
npx sanity functions test classify-conversations --with-user-tokenHow It Works
Conversation Saving
The sanityInsightsIntegration hooks into AI SDK's telemetry system:
- On request start: Captures input messages
- On request finish: Combines with response messages and saves to Sanity
Conversations are saved as sanity.agentContextConversation documents (provided by the plugin).
Classification
The getConversationsToClassify primitive finds conversations that:
- Have never been classified (
classifiedAtnot set) - Have been updated since last classification (
_updatedAt > classifiedAt) - Have been idle for at least 10 minutes to avoid classifying active conversations
The classifyConversation primitive:
1. Sends messages to an LLM with a classification prompt 2. Extracts metrics: success score, sentiment, content gaps 3. Updates the conversation document with results
Telemetry
The telemetry option on classifyConversation lets you share classification data with the Sanity team to help improve Sanity Context. This is fully opt-in and off by default.
There are two tiers:
Metadata-only (shareMetrics: true): Shares classification metrics (success scores, sentiment, content gap counts), message shapes (roles, byte sizes, tool names), model info, and token usage. No conversation content is transmitted — we cannot see what your users or agent said.
Full conversation sharing (shareConversations: true): Additionally shares the actual message contents. This lets the Sanity team analyze real conversations to identify patterns, suggest improvements to your agent configuration, and help you get better results. Provide a contact so the team can reach out and collaborate with you directly.
If you can, enabling metadata-only telemetry helps us prioritize improvements. If you want hands-on help tuning your agent, enable full sharing and the team will be in touch.
Troubleshooting
Function not running
- Did you run
npx sanity blueprints promote? Scheduled functions require org-level scope. - Check logs:
npx sanity functions logs classify-conversations
"No client token available"
In production: The robot token isn't configured correctly. Verify:
robotTokenin the blueprint matches the robot token resource name (e.g.$.resources.classify-conversations-robot.token)- The
resourceIdindefineRobotTokenis your actual project ID
During local testing: Run with --with-user-token to inject your personal token into context.clientOptions.
Classification not finding conversations
- Conversations need at least 10 minutes of idle time before classification
- Check that telemetry is saving conversations: look for
sanity.agentContextConversationdocuments in Studio
Insights API Reference
sanityInsightsIntegration
import {sanityInsightsIntegration} from '@sanity/context/ai-sdk'
sanityInsightsIntegration({
client: SanityClient, // Write client (Editor permissions)
agentId: string | (() => string), // Agent identifier
threadId: string | (() => string), // Thread identifier
})classifyConversations
The recommended way to classify conversations. Handles fetching, batching, and error handling in a single call:
import {classifyConversations} from '@sanity/context/insights'
const result = await classifyConversations({
client: SanityClient,
model: LanguageModel, // Any AI SDK compatible model
telemetry?: TelemetryConfig, // Optional: share metrics with Sanity
agentId?: string, // Optional: filter by agent
limit?: number, // Optional: max conversations to process
cooldownMinutes?: number, // Optional: idle time before eligibility (default 10)
concurrency?: number, // Optional: parallel classifications (default 5)
})
// Returns: { successCount, errorCount, totalFound }Lower-level Primitives
For custom workflows, use the individual primitives directly:
getConversationsToClassify({client, agentId?, limit?, cooldownMinutes?})— Find conversations needing classificationgetPreviousContentGaps({client, agentId?, maxAgeDays?, limit?})— Fetch content gaps ranked by frequencyclassifyConversation({client, conversationId, model, messages, ...})— Classify a single conversation
import {classifyConversation, getConversationsToClassify, getPreviousContentGaps} from '@sanity/context/insights'Opting Out
If you don't need Insights, disable it in the plugin:
contextPlugin({insights: {enabled: false}})This removes the conversation schema and dashboard from your Studio.
Ecommerce Reference Implementation
Complete working example of a Next.js e-commerce site with AI shopping assistant powered by Sanity Context MCP.
Auto-synced from examples/ecommerce/. Do not edit directly.When to Load Files
| Task | Load These Files |
|---|---|
| MCP connection setup | app/src/app/api/chat/route.ts (createMCPClient) |
| System prompt from Sanity | app/src/app/api/chat/route.ts (buildSystemPrompt), studio/schemaTypes/documents/agentConfig.ts |
| Client-side tool handling | app/src/components/chat/chat.tsx (onToolCall), app/src/lib/client-tools.ts |
| Page context capture | app/src/lib/capture-context.ts |
| Custom markdown rendering | app/src/components/chat/message/text-part.tsx |
| Studio plugin setup | studio/sanity.config.ts |
| Schema design patterns | studio/schemaTypes/documents/product.ts, studio/schemaTypes/index.ts |
| Sanity client/queries | app/src/sanity/lib/client.ts, app/src/sanity/queries/ |
| Conversation insights | app/src/app/api/chat/route.ts (sanityInsightsIntegration), functions/classify-conversations/index.ts |
| Environment variables | .env |
File Map
Agent Integration (Core)
app/src/app/api/chat/route.ts # API route: MCP client, tools, streaming, insights
app/src/lib/client-tools.ts # Tool constants shared server/client
app/src/lib/capture-context.ts # Page context & screenshot captureChat UI
app/src/components/chat/
├── chat.tsx # Main component: useChat, tool handling
├── chat-input.tsx # Input field
├── chat-button.tsx # Floating button to open chat
├── loader.tsx # Loading indicator
├── tool-call.tsx # Debug tool call display
└── message/
├── message.tsx # Message rendering
├── text-part.tsx # Text with markdown + directive parsing
├── document.tsx # Document directive router
└── product.tsx # Product card componentScheduled Functions (Project Root)
./
├── sanity.blueprint.ts # Scheduled function config
├── functions/
│ └── classify-conversations/
│ └── index.ts # Scheduled classification functionSanity Studio
studio/
├── sanity.config.ts # Plugin setup (includes contextPlugin)
└── schemaTypes/
├── index.ts # Schema registration
├── documents/
│ ├── product.ts # Product schema
│ ├── category.ts # Category schema
│ ├── brand.ts # Brand schema
│ ├── agentConfig.ts # Agent system prompt config
│ └── ...
└── objects/
├── productVariant.ts # Variant (size/color combos)
├── price.ts # Price object
└── seo.ts # SEO metadataSanity Queries & Client
app/src/sanity/
├── lib/
│ ├── client.ts # Sanity client setup
│ ├── write-client.ts # Write client for insights
│ └── image.ts # Image URL builder
└── queries/
├── products.ts # Product queries
├── categories.ts # Category queries
└── fragments.ts # Reusable GROQ fragmentsProduct Pages (Context for Agent)
app/src/app/
├── page.tsx # Homepage
└── products/
├── page.tsx # Product listing
└── [slug]/page.tsx # Product detailKey Patterns
MCP Connection
See app/src/app/api/chat/route.ts (createMCPClient)
Client Tools (No Server Execute)
See app/src/app/api/chat/route.ts (clientTools)
System Prompt from Sanity
See app/src/app/api/chat/route.ts (buildSystemPrompt)
Tool Handling on Client
See app/src/components/chat/chat.tsx (onToolCall)
Auto-continuation
See app/src/components/chat/chat.tsx (sendAutomaticallyWhen)
Custom Directives
See app/src/components/chat/message/text-part.tsx (uses @sanity/agent-directives)
Conversation Insights
Conversations are automatically saved via sanityInsightsIntegration in the chat route. Classification runs every 10 minutes via a scheduled function in functions/classify-conversations/index.ts.
# Ecommerce Demo Configuration
# Copy this file to .env and fill in your values
# Sanity project
SANITY_STUDIO_PROJECT_ID=
SANITY_STUDIO_DATASET=
# SANITY_STUDIO_API_HOST= # Optional, defaults to https://api.sanity.io
# Next.js app (derived from above, NEXT_PUBLIC_ prefix exposes to browser)
NEXT_PUBLIC_SANITY_PROJECT_ID=${SANITY_STUDIO_PROJECT_ID}
NEXT_PUBLIC_SANITY_DATASET=${SANITY_STUDIO_DATASET}
# NEXT_PUBLIC_SANITY_API_HOST=${SANITY_STUDIO_API_HOST}
# Chat assistant
# Read token: Create in Sanity Manage with "Viewer" role for MCP context queries
SANITY_API_READ_TOKEN=
# Write token: Create in Sanity Manage with "Editor" role for saving conversations
# Required for conversation classification feature
SANITY_API_WRITE_TOKEN=
# MCP server URL for Sanity Context (get from the Sanity Context plugin in Studio)
SANITY_CONTEXT_MCP_URL=
# Anthropic API key for Claude AI chat assistant
ANTHROPIC_API_KEY=
# Anthropic model to use (optional, defaults to claude-sonnet-4-5)
# ANTHROPIC_MODEL=claude-sonnet-4-5
# Agent config slug (matches agent.config document in Studio)
AGENT_CONFIG_SLUG=default
# Environment files
.env
.env.local
.env.*.local
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# sanity typegen - committed for Vercel builds
# sanity.types.ts
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
import {defineConfig} from 'eslint/config'
import nextVitals from 'eslint-config-next/core-web-vitals'
import nextTs from 'eslint-config-next/typescript'
const eslintConfig = defineConfig([...nextVitals, ...nextTs])
export default eslintConfig
import {config} from 'dotenv'
import type {NextConfig} from 'next'
// Load env from parent directory's .env file
config({path: '../.env'})
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.sanity.io',
},
{
protocol: 'https',
hostname: 'cdn.sanity.work',
},
],
},
}
export default nextConfig
{
"name": "@examples/ecommerce-app",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",
"check:lint": "eslint .",
"check:lint:fix": "eslint . --fix",
"check:types": "tsc --noEmit",
"dev": "next dev",
"start": "next start",
"typegen": "sanity typegen generate"
},
"prettier": "@sanity/prettier-config",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.76",
"@ai-sdk/mcp": "^1.0.41",
"@ai-sdk/react": "^3.0.179",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@sanity/agent-directives": "^0.0.15",
"@sanity/client": "^7.21.0",
"@sanity/context": "workspace:*",
"@sanity/image-url": "^2.0.3",
"ai": "^6.0.177",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"html2canvas-pro": "^2.0.2",
"lucide-react": "^0.577.0",
"next": "16.1.7",
"next-sanity": "^12.1.1",
"react": "^19",
"react-dom": "^19",
"react-markdown": "^10.1.0",
"sanity": "^5.22.0",
"server-only": "^0.0.1",
"swr": "^2.4.1",
"tailwind-merge": "^3.5.0",
"turndown": "^7.2.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^22.19.15",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/turndown": "^5.0.6",
"dotenv": "^17.3.1",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4.2.1",
"typescript": "^5"
}
}
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg><svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg><svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg><svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>{
"path": "./src/**/*.{ts,tsx}",
"schema": "../studio/schema.json",
"generates": "./sanity.types.ts"
}
import {config} from 'dotenv'
import {defineCliConfig} from 'sanity/cli'
// Load env from parent directory's .env file
config({path: '../.env'})
const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET || 'production'
if (!projectId) {
throw new Error('Missing NEXT_PUBLIC_SANITY_PROJECT_ID environment variable')
}
export default defineCliConfig({
api: {
projectId,
dataset,
},
})
/**
* ---------------------------------------------------------------------------------
* This file has been generated by Sanity TypeGen.
* Command: `sanity typegen generate`
*
* Any modifications made directly to this file will be overwritten the next time
* the TypeScript definitions are generated. Please make changes to the Sanity
* schema definitions and/or GROQ queries if you need to update these types.
*
* For more information on how to use Sanity TypeGen, visit the official documentation:
* https://www.sanity.io/docs/sanity-typegen
* ---------------------------------------------------------------------------------
*/
// Source: ../studio/schema.json
export type CategoryReference = {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'category'
}
export type BrandReference = {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'brand'
}
export type MaterialReference = {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'material'
}
export type Product = {
_id: string
_type: 'product'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
slug?: Slug
sku?: string
shortDescription?: string
description?: Array<{
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'normal'
listItem?: 'bullet' | 'number'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}>
category?: CategoryReference
brand?: BrandReference
materials?: Array<
{
_key: string
} & MaterialReference
>
careInstructions?: string
tags?: Array<string>
features?: Array<string>
price?: Price
variants?: Array<
{
_key: string
} & ProductVariant
>
seo?: Seo
}
export type Seo = {
_type: 'seo'
metaTitle?: string
metaDescription?: string
keywords?: Array<string>
}
export type Price = {
_type: 'price'
amount?: number
compareAtPrice?: number
}
export type Slug = {
_type: 'slug'
current?: string
source?: string
}
export type Material = {
_id: string
_type: 'material'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
slug?: Slug
composition?: string
}
export type Category = {
_id: string
_type: 'category'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
slug?: Slug
parent?: CategoryReference
description?: string
}
export type Size = {
_id: string
_type: 'size'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
code?: string
sortOrder?: number
}
export type SanityImageAssetReference = {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
export type Brand = {
_id: string
_type: 'brand'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
slug?: Slug
description?: string
logo?: {
asset?: SanityImageAssetReference
media?: unknown
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
}
}
export type SanityImageCrop = {
_type: 'sanity.imageCrop'
top?: number
bottom?: number
left?: number
right?: number
}
export type SanityImageHotspot = {
_type: 'sanity.imageHotspot'
x?: number
y?: number
height?: number
width?: number
}
export type ColorReference = {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'color'
}
export type SizeReference = {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'size'
}
export type ProductVariant = {
_type: 'productVariant'
color?: ColorReference
sizes?: Array<
{
_key: string
} & SizeReference
>
sku?: string
images?: Array<{
asset?: SanityImageAssetReference
media?: unknown
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
_type: 'image'
_key: string
}>
available?: boolean
}
export type Color = {
_id: string
_type: 'color'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
slug?: Slug
hexValue?: string
}
export type SanityAgentContext = {
_id: string
_type: 'sanity.agentContext'
_createdAt: string
_updatedAt: string
_rev: string
name?: string
slug?: Slug
groqFilter?: string
}
export type SanityImagePaletteSwatch = {
_type: 'sanity.imagePaletteSwatch'
background?: string
foreground?: string
population?: number
title?: string
}
export type SanityImagePalette = {
_type: 'sanity.imagePalette'
darkMuted?: SanityImagePaletteSwatch
lightVibrant?: SanityImagePaletteSwatch
darkVibrant?: SanityImagePaletteSwatch
vibrant?: SanityImagePaletteSwatch
dominant?: SanityImagePaletteSwatch
lightMuted?: SanityImagePaletteSwatch
muted?: SanityImagePaletteSwatch
}
export type SanityImageDimensions = {
_type: 'sanity.imageDimensions'
height?: number
width?: number
aspectRatio?: number
}
export type SanityImageMetadata = {
_type: 'sanity.imageMetadata'
location?: Geopoint
dimensions?: SanityImageDimensions
palette?: SanityImagePalette
lqip?: string
blurHash?: string
thumbHash?: string
hasAlpha?: boolean
isOpaque?: boolean
}
export type SanityFileAsset = {
_id: string
_type: 'sanity.fileAsset'
_createdAt: string
_updatedAt: string
_rev: string
originalFilename?: string
label?: string
title?: string
description?: string
altText?: string
sha1hash?: string
extension?: string
mimeType?: string
size?: number
assetId?: string
uploadId?: string
path?: string
url?: string
source?: SanityAssetSourceData
}
export type SanityAssetSourceData = {
_type: 'sanity.assetSourceData'
name?: string
id?: string
url?: string
}
export type SanityImageAsset = {
_id: string
_type: 'sanity.imageAsset'
_createdAt: string
_updatedAt: string
_rev: string
originalFilename?: string
label?: string
title?: string
description?: string
altText?: string
sha1hash?: string
extension?: string
mimeType?: string
size?: number
assetId?: string
uploadId?: string
path?: string
url?: string
metadata?: SanityImageMetadata
source?: SanityAssetSourceData
}
export type Geopoint = {
_type: 'geopoint'
lat?: number
lng?: number
alt?: number
}
export type AllSanitySchemaTypes =
| CategoryReference
| BrandReference
| MaterialReference
| Product
| Seo
| Price
| Slug
| Material
| Category
| Size
| SanityImageAssetReference
| Brand
| SanityImageCrop
| SanityImageHotspot
| ColorReference
| SizeReference
| ProductVariant
| Color
| SanityAgentContext
| SanityImagePaletteSwatch
| SanityImagePalette
| SanityImageDimensions
| SanityImageMetadata
| SanityFileAsset
| SanityAssetSourceData
| SanityImageAsset
| Geopoint
export declare const internalGroqTypeReferenceTo: unique symbol
// Source: src/sanity/queries/categories.ts
// Variable: CATEGORIES_QUERY
// Query: *[_type == "category" && defined(slug.current)] | order(title asc) { _id, title, "slug": slug.current, description }
export type CATEGORIES_QUERY_RESULT = Array<{
_id: string
title: string | null
slug: string | null
description: string | null
}>
// Source: src/sanity/queries/categories.ts
// Variable: CATEGORY_QUERY
// Query: *[_type == "category" && slug.current == $slug][0] { _id, title, "slug": slug.current, description }
export type CATEGORY_QUERY_RESULT = {
_id: string
title: string | null
slug: string | null
description: string | null
} | null
// Source: src/sanity/queries/filters.ts
// Variable: FILTER_OPTIONS_QUERY
// Query: { "categories": *[_type == "category" && defined(slug.current)] | order(title asc) { _id, title, "slug": slug.current }, "colors": *[_type == "color" && defined(slug.current)] | order(title asc) { _id, title, "slug": slug.current, hexValue }, "sizes": *[_type == "size"] | order(sortOrder asc) { _id, title, code, sortOrder }, "brands": *[_type == "brand" && defined(slug.current)] | order(title asc) { _id, title, "slug": slug.current }, "priceRange": { "min": math::min(*[_type == "product" && defined(price.amount)].price.amount), "max": math::max(*[_type == "product" && defined(price.amount)].price.amount) }}
export type FILTER_OPTIONS_QUERY_RESULT = {
categories: Array<{
_id: string
title: string | null
slug: string | null
}>
colors: Array<{
_id: string
title: string | null
slug: string | null
hexValue: string | null
}>
sizes: Array<{
_id: string
title: string | null
code: string | null
sortOrder: number | null
}>
brands: Array<{
_id: string
title: string | null
slug: string | null
}>
priceRange: {
min: number | null
max: number | null
}
}
// Source: src/sanity/queries/products.ts
// Variable: PRODUCTS_QUERY
// Query: *[_type == "product" && defined(slug.current)] | order(_createdAt desc) { _id, title, "slug": slug.current, shortDescription, "category": category->{ _id, title, "slug": slug.current }, "brand": brand->{ _id, title, "slug": slug.current }, "image": variants[0].images[0] { asset->{ _id, url, metadata { lqip, dimensions } }, alt }, price { amount, compareAtPrice } }
export type PRODUCTS_QUERY_RESULT = Array<{
_id: string
title: string | null
slug: string | null
shortDescription: string | null
category: {
_id: string
title: string | null
slug: string | null
} | null
brand: {
_id: string
title: string | null
slug: string | null
} | null
image: {
asset: {
_id: string
url: string | null
metadata: {
lqip: string | null
dimensions: SanityImageDimensions | null
} | null
} | null
alt: string | null
} | null
price: {
amount: number | null
compareAtPrice: number | null
} | null
}>
// Source: src/sanity/queries/products.ts
// Variable: FEATURED_PRODUCTS_QUERY
// Query: *[_type == "product" && defined(slug.current)] | order(_createdAt desc) [0...8] { _id, title, "slug": slug.current, shortDescription, "category": category->{ _id, title, "slug": slug.current }, "brand": brand->{ _id, title, "slug": slug.current }, "image": variants[0].images[0] { asset->{ _id, url, metadata { lqip, dimensions } }, alt }, price { amount, compareAtPrice } }
export type FEATURED_PRODUCTS_QUERY_RESULT = Array<{
_id: string
title: string | null
slug: string | null
shortDescription: string | null
category: {
_id: string
title: string | null
slug: string | null
} | null
brand: {
_id: string
title: string | null
slug: string | null
} | null
image: {
asset: {
_id: string
url: string | null
metadata: {
lqip: string | null
dimensions: SanityImageDimensions | null
} | null
} | null
alt: string | null
} | null
price: {
amount: number | null
compareAtPrice: number | null
} | null
}>
// Source: src/sanity/queries/products.ts
// Variable: PRODUCT_QUERY
// Query: *[_type == "product" && slug.current == $slug][0] { _id, title, "slug": slug.current, sku, shortDescription, description, features, careInstructions, "category": category->{ _id, title, "slug": slug.current }, "brand": brand->{ _id, title, "slug": slug.current }, price { amount, compareAtPrice }, "materials": materials[]->{ _id, title }, "variants": variants[] { _key, sku, available, "color": color->{ _id, title, hex }, "sizes": sizes[]->{ _id, title, code, sortOrder }, "images": images[] { asset->{ _id, url, metadata { lqip, dimensions } }, alt } } }
export type PRODUCT_QUERY_RESULT = {
_id: string
title: string | null
slug: string | null
sku: string | null
shortDescription: string | null
description: Array<{
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'normal'
listItem?: 'bullet' | 'number'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}> | null
features: Array<string> | null
careInstructions: string | null
category: {
_id: string
title: string | null
slug: string | null
} | null
brand: {
_id: string
title: string | null
slug: string | null
} | null
price: {
amount: number | null
compareAtPrice: number | null
} | null
materials: Array<{
_id: string
title: string | null
}> | null
variants: Array<{
_key: string
sku: string | null
available: boolean | null
color: {
_id: string
title: string | null
hex: null
} | null
sizes: Array<{
_id: string
title: string | null
code: string | null
sortOrder: number | null
}> | null
images: Array<{
asset: {
_id: string
url: string | null
metadata: {
lqip: string | null
dimensions: SanityImageDimensions | null
} | null
} | null
alt: string | null
}> | null
}> | null
} | null
// Source: src/sanity/queries/products.ts
// Variable: PRODUCT_SLUGS_QUERY
// Query: *[_type == "product" && defined(slug.current)] { "slug": slug.current }
export type PRODUCT_SLUGS_QUERY_RESULT = Array<{
slug: string | null
}>
// Source: src/sanity/queries/products.ts
// Variable: PRODUCTS_COUNT_QUERY
// Query: count(*[_type == "product" && defined(slug.current)])
export type PRODUCTS_COUNT_QUERY_RESULT = number
// Query TypeMap
import '@sanity/client'
declare module '@sanity/client' {
interface SanityQueries {
'\n *[_type == "category" && defined(slug.current)] | order(title asc) {\n _id,\n title,\n "slug": slug.current,\n description\n }\n': CATEGORIES_QUERY_RESULT
'\n *[_type == "category" && slug.current == $slug][0] {\n _id,\n title,\n "slug": slug.current,\n description\n }\n': CATEGORY_QUERY_RESULT
'{\n "categories": *[_type == "category" && defined(slug.current)] | order(title asc) {\n _id,\n title,\n "slug": slug.current\n },\n "colors": *[_type == "color" && defined(slug.current)] | order(title asc) {\n _id,\n title,\n "slug": slug.current,\n hexValue\n },\n "sizes": *[_type == "size"] | order(sortOrder asc) {\n _id,\n title,\n code,\n sortOrder\n },\n "brands": *[_type == "brand" && defined(slug.current)] | order(title asc) {\n _id,\n title,\n "slug": slug.current\n },\n "priceRange": {\n "min": math::min(*[_type == "product" && defined(price.amount)].price.amount),\n "max": math::max(*[_type == "product" && defined(price.amount)].price.amount)\n }\n}': FILTER_OPTIONS_QUERY_RESULT
'\n *[_type == "product" && defined(slug.current)] | order(_createdAt desc) {\n \n _id,\n title,\n "slug": slug.current,\n shortDescription,\n "category": category->{ \n _id,\n title,\n "slug": slug.current\n },\n "brand": brand->{ \n _id,\n title,\n "slug": slug.current\n },\n "image": variants[0].images[0] { \n asset->{\n _id,\n url,\n metadata { lqip, dimensions }\n },\n alt\n },\n price { \n amount,\n compareAtPrice\n }\n\n }\n': PRODUCTS_QUERY_RESULT
'\n *[_type == "product" && defined(slug.current)] | order(_createdAt desc) [0...8] {\n \n _id,\n title,\n "slug": slug.current,\n shortDescription,\n "category": category->{ \n _id,\n title,\n "slug": slug.current\n },\n "brand": brand->{ \n _id,\n title,\n "slug": slug.current\n },\n "image": variants[0].images[0] { \n asset->{\n _id,\n url,\n metadata { lqip, dimensions }\n },\n alt\n },\n price { \n amount,\n compareAtPrice\n }\n\n }\n': FEATURED_PRODUCTS_QUERY_RESULT
'\n *[_type == "product" && slug.current == $slug][0] {\n _id,\n title,\n "slug": slug.current,\n sku,\n shortDescription,\n description,\n features,\n careInstructions,\n "category": category->{ \n _id,\n title,\n "slug": slug.current\n },\n "brand": brand->{ \n _id,\n title,\n "slug": slug.current\n },\n price { \n amount,\n compareAtPrice\n },\n "materials": materials[]->{ _id, title },\n "variants": variants[] { \n _key,\n sku,\n available,\n "color": color->{ _id, title, hex },\n "sizes": sizes[]->{ _id, title, code, sortOrder },\n "images": images[] { \n asset->{\n _id,\n url,\n metadata { lqip, dimensions }\n },\n alt\n }\n }\n }\n': PRODUCT_QUERY_RESULT
'\n *[_type == "product" && defined(slug.current)] {\n "slug": slug.current\n }\n': PRODUCT_SLUGS_QUERY_RESULT
'\n count(*[_type == "product" && defined(slug.current)])\n': PRODUCTS_COUNT_QUERY_RESULT
}
}
import {anthropic} from '@ai-sdk/anthropic'
import {createMCPClient, type MCPClient} from '@ai-sdk/mcp'
import {sanityInsightsIntegration} from '@sanity/context/ai-sdk'
import {
convertToModelMessages,
type Experimental_DownloadFunction,
stepCountIs,
streamText,
type UIMessage,
} from 'ai'
import {clientTools, type DocumentContext} from '@/lib/client-tools'
import {client} from '@/sanity/lib/client'
import {writeClient} from '@/sanity/lib/write-client'
const DEFAULT_MODEL = 'claude-sonnet-4-5'
const MAX_STEPS = 20
let cachedInitialContext: string | null = null
let cacheTimestamp = 0
const CACHE_TTL_MS = 5 * 60 * 1000
function initialContextUrl(mcpUrl: string): string {
const url = new URL(mcpUrl)
url.pathname = `${url.pathname.replace(/\/$/, '')}/initial-context`
return url.toString()
}
// Slow on cold start — subsequent calls return the cached result
async function fetchInitialContext(): Promise<string | null> {
const mcpUrl = process.env.SANITY_CONTEXT_MCP_URL
if (!mcpUrl) return null
const isStale = Date.now() - cacheTimestamp > CACHE_TTL_MS
const fetchPromise = isStale
? fetch(initialContextUrl(mcpUrl), {
headers: {Authorization: `Bearer ${process.env.SANITY_API_READ_TOKEN}`},
})
.then(async (res) => {
if (res.ok) {
cachedInitialContext = await res.text()
cacheTimestamp = Date.now()
}
})
.catch(() => {})
: null
if (!cachedInitialContext) await fetchPromise
return cachedInitialContext
}
interface BuildSystemPromptParams {
basePrompt: string
documentContext: DocumentContext
initialContext?: string | null
}
/**
* Combines base prompt from Sanity with page context and tool instructions.
*/
function buildSystemPrompt(props: BuildSystemPromptParams): string {
const {basePrompt, documentContext, initialContext} = props
return `
${basePrompt}
${initialContext ? `\n# Data reference\n\nUse this to understand what's available and write better queries.\n\n${initialContext}\n` : ''}
# Current page
<page-context>
<title>${documentContext.title}</title>
<description>${documentContext.description || ''}</description>
<pathname>${documentContext.pathname}</pathname>
</page-context>
For more detail about what's visible on the page, use these tools:
- **get_page_context**: page content as markdown
- **get_page_screenshot**: visual screenshot
# Displaying products
To display products as rich cards, query Sanity to get the _id and _type, then use this directive syntax:
- Block: ::document{id="<_id>" type="<_type>"}
- Inline: :document{id="<_id>" type="<_type>"}
Always use directives for product names so the UI can render them as cards.
`
}
interface ChatRequest {
messages: UIMessage[]
documentContext: DocumentContext
id: string
}
// The `get_page_screenshot` tool sends screenshots as `data:` URLs, which
// are not supported by the default downloader. `experimental_download` is used
// to decode `data:` files for model input. An alternative approach is to upload
// screenshots first and send an `https://` file URL.
const downloadDataUrls: Experimental_DownloadFunction = async (items) => {
return items.map(({url}) => {
if (url.protocol !== 'data:') return null
const [meta = '', payload = ''] = url.href.slice(5).split(',', 2)
const mediaType = meta.split(';')[0] || undefined
const data = meta.includes(';base64')
? Buffer.from(payload, 'base64')
: Buffer.from(decodeURIComponent(payload), 'utf8')
return {data: new Uint8Array(data), mediaType}
})
}
export async function POST(req: Request) {
const {messages, documentContext, id: chatId}: ChatRequest = await req.json()
if (!process.env.SANITY_CONTEXT_MCP_URL) {
throw new Error('SANITY_CONTEXT_MCP_URL is not set')
}
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error('ANTHROPIC_API_KEY is not set')
}
if (!process.env.SANITY_API_READ_TOKEN) {
throw new Error('SANITY_API_READ_TOKEN is not set')
}
let mcpClient: MCPClient | null = null
try {
const [mcpClientResult, agentConfig, initialContext] = await Promise.all([
createMCPClient({
transport: {
type: 'http',
url: process.env.SANITY_CONTEXT_MCP_URL,
headers: {
Authorization: `Bearer ${process.env.SANITY_API_READ_TOKEN}`,
},
},
}),
client.fetch<{systemPrompt: string | null} | null>(
`*[_type == "agent.config" && slug.current == $slug][0] { systemPrompt }`,
{slug: process.env.AGENT_CONFIG_SLUG || 'default'},
),
fetchInitialContext(),
])
mcpClient = mcpClientResult
if (!agentConfig?.systemPrompt) {
await mcpClient?.close()
return Response.json(
{error: 'Agent config not found or missing system prompt. Create one in Sanity Studio.'},
{status: 500},
)
}
const systemPrompt = buildSystemPrompt({
basePrompt: agentConfig.systemPrompt,
documentContext,
initialContext,
})
const allMcpTools = await mcpClient.tools()
// Exclude initial_context tool, its data is already in the system prompt
const {initial_context: _, ...mcpTools} = allMcpTools
const modelId = process.env.ANTHROPIC_MODEL || DEFAULT_MODEL
const result = streamText({
model: anthropic(modelId),
system: systemPrompt,
messages: await convertToModelMessages(messages),
experimental_download: downloadDataUrls,
tools: {
...mcpTools,
...clientTools,
},
stopWhen: stepCountIs(MAX_STEPS),
experimental_telemetry: {
isEnabled: true,
integrations: [
sanityInsightsIntegration({
client: writeClient,
agentId: 'shopping-assistant',
threadId: chatId,
}),
],
},
onFinish: async () => {
await mcpClient?.close()
},
})
return result.toUIMessageStreamResponse({
originalMessages: messages,
})
} catch (error) {
await mcpClient?.close()
return Response.json(
{error: error instanceof Error ? error.message : 'An unexpected error occurred'},
{status: 500},
)
}
}
@import 'tailwindcss';
:root {
--background: #ffffff;
--foreground: #0a0a0a;
}
body {
background: var(--background);
color: var(--foreground);
}
import './globals.css'
import type {Metadata} from 'next'
import {Inter} from 'next/font/google'
import {ChatButton} from '@/components/chat/chat-button'
import {Header} from '@/components/header'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
})
export const metadata: Metadata = {
title: 'Store | E-commerce Demo',
description: 'A minimal e-commerce demo built with Next.js and Sanity',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className={`${inter.className} antialiased`}>
<Header />
{children}
<ChatButton />
</body>
</html>
)
}
import Link from 'next/link'
import {ProductGrid} from '@/components/product-grid'
import {Button} from '@/components/ui/button'
import {client} from '@/sanity/lib/client'
import {FEATURED_PRODUCTS_QUERY} from '@/sanity/queries'
export default async function HomePage() {
const products = await client.fetch(FEATURED_PRODUCTS_QUERY)
return (
<main>
{/* Hero */}
<section className="border-b border-neutral-200 bg-neutral-50 px-4 py-16 text-center md:py-24">
<h1 className="mx-auto max-w-2xl text-3xl font-semibold tracking-tight md:text-4xl">
Quality essentials for everyday life
</h1>
<p className="mx-auto mt-4 max-w-md text-neutral-600">
Thoughtfully designed clothing that combines comfort with timeless style.
</p>
<div className="mt-8">
<Button asChild size="lg">
<Link href="/products">Shop All</Link>
</Button>
</div>
</section>
{/* Featured Products */}
<section className="mx-auto max-w-7xl px-4 py-12 md:py-16">
<h2 className="mb-8 text-xl font-semibold">Featured Products</h2>
<ProductGrid products={products} />
</section>
</main>
)
}
import Link from 'next/link'
import {notFound} from 'next/navigation'
import type {Metadata} from 'next/types'
import {ProductDetails} from '@/components/product-details'
import {client} from '@/sanity/lib/client'
import {PRODUCT_QUERY, PRODUCT_SLUGS_QUERY} from '@/sanity/queries'
import type {PRODUCT_QUERY_RESULT, PRODUCT_SLUGS_QUERY_RESULT} from '../../../../sanity.types'
interface Props {
params: Promise<{slug: string}>
}
// Derive types from the generated query result
type Product = NonNullable<PRODUCT_QUERY_RESULT>
type ProductVariant = NonNullable<Product['variants']>[number]
type VariantColor = NonNullable<ProductVariant['color']>
type VariantSize = NonNullable<NonNullable<ProductVariant['sizes']>[number]>
export async function generateStaticParams() {
const products: PRODUCT_SLUGS_QUERY_RESULT = await client.fetch(PRODUCT_SLUGS_QUERY)
return products.filter((p) => p.slug).map((p) => ({slug: p.slug!}))
}
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {slug} = await params
const product = await client.fetch(PRODUCT_QUERY, {slug})
if (!product) return {title: 'Product Not Found'}
return {
title: `${product.title} | Store`,
description: product.shortDescription || `Shop ${product.title}`,
}
}
export default async function ProductPage({params}: Props) {
const {slug} = await params
const product: PRODUCT_QUERY_RESULT = await client.fetch(PRODUCT_QUERY, {slug})
if (!product) {
notFound()
}
const {title, price, category, shortDescription, features, materials, variants, brand} = product
// Get unique colors and sizes from variants (types inferred from PRODUCT_QUERY_RESULT)
const colorMap = new Map<string, VariantColor>()
const sizeMap = new Map<string, VariantSize>()
variants?.forEach((v) => {
if (v.color?._id) colorMap.set(v.color._id, v.color)
// Each variant now has an array of sizes
v.sizes?.forEach((size) => {
if (size._id) sizeMap.set(size._id, size)
})
})
const colors = [...colorMap.values()]
const sizes = [...sizeMap.values()].sort((a, b) => (a.sortOrder ?? 99) - (b.sortOrder ?? 99))
return (
<main className="mx-auto max-w-7xl px-4 py-8 md:py-12">
{/* Breadcrumb */}
<nav className="mb-6 text-sm text-neutral-500">
<Link href="/products" className="hover:text-neutral-900">
Products
</Link>
{category && (
<>
<span className="mx-2">/</span>
<span>{category.title}</span>
</>
)}
</nav>
<ProductDetails
title={title}
brand={brand}
category={category}
shortDescription={shortDescription}
price={price}
features={features}
materials={materials}
colors={colors}
sizes={sizes}
variants={variants}
/>
</main>
)
}
export default function Loading() {
return (
<main className="mx-auto max-w-7xl px-4 py-8 md:py-12">
<div className="mb-8 h-8 w-40 animate-pulse rounded bg-neutral-200" />
<div className="grid grid-cols-2 gap-4 sm:gap-6 md:grid-cols-3 lg:grid-cols-4">
{Array.from({length: 12}).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="aspect-square rounded bg-neutral-200" />
<div className="mt-3 h-4 w-3/4 rounded bg-neutral-200" />
<div className="mt-2 h-4 w-1/2 rounded bg-neutral-200" />
</div>
))}
</div>
</main>
)
}
import {Suspense} from 'react'
import {FilterBar} from '@/components/filter-bar'
import {ProductGrid} from '@/components/product-grid'
import {ProductPagination} from '@/components/product-pagination'
import {type ProductFiltersInput} from '@/lib/client-tools'
import {client} from '@/sanity/lib/client'
import {FILTER_OPTIONS_QUERY} from '@/sanity/queries/filters'
import {
buildFilteredProductsCountQuery,
buildFilteredProductsQuery,
PAGE_SIZE,
SORT_OPTIONS,
} from '@/sanity/queries/products'
import type {FILTER_OPTIONS_QUERY_RESULT} from '../../../sanity.types'
export const metadata = {
title: 'All Products | Store',
description: 'Browse our collection of quality essentials.',
}
interface ProductsPageProps {
searchParams: Promise<{
page?: string
category?: string | string[]
color?: string | string[]
size?: string | string[]
brand?: string | string[]
minPrice?: string
maxPrice?: string
sort?: string
}>
}
// Convert URL param (string or string[]) to string[] or undefined
function toArray(value: string | string[] | undefined): string[] | undefined {
if (!value) return undefined
if (Array.isArray(value)) return value.length > 0 ? value : undefined
return [value]
}
export default async function ProductsPage(props: ProductsPageProps) {
const {searchParams} = props
const params = await searchParams
const currentPage = Number(params.page) || 1
// Build filters from URL params (multi-value params become arrays)
const validSort = SORT_OPTIONS.find((s) => s.value === params.sort)
const filters: ProductFiltersInput = {
category: toArray(params.category),
color: toArray(params.color),
size: toArray(params.size),
brand: toArray(params.brand),
minPrice: params.minPrice ? Number(params.minPrice) : undefined,
maxPrice: params.maxPrice ? Number(params.maxPrice) : undefined,
sort: validSort?.value,
}
// Build dynamic queries based on filters
const productsQuery = buildFilteredProductsQuery(filters)
const countQuery = buildFilteredProductsCountQuery(filters)
// Fetch filter options, products, and count in parallel
const [filterOptions, products, totalCount] = await Promise.all([
client.fetch(FILTER_OPTIONS_QUERY),
client.fetch(productsQuery, {page: currentPage}),
client.fetch<number>(countQuery),
])
const totalPages = Math.ceil(totalCount / PAGE_SIZE)
// Generate description based on active filters
const activeFilterLabels = getActiveFilterLabels(filters, filterOptions)
return (
<main className="mx-auto max-w-7xl px-4 py-8 md:py-12">
<div className="mb-8">
<h1 className="mb-2 text-2xl font-semibold">
{activeFilterLabels.length > 0 ? activeFilterLabels.join(' · ') : 'All Products'}
</h1>
<p className="text-sm text-neutral-500">
{`${totalCount} ${totalCount === 1 ? 'product' : 'products'}`}
</p>
</div>
{/* Filter Bar */}
<div className="mb-8">
<Suspense fallback={<FilterBarSkeleton />}>
<FilterBar filterOptions={filterOptions} />
</Suspense>
</div>
{/* Products Grid */}
{products.length > 0 ? (
<ProductGrid products={products} />
) : (
<div className="py-16 text-center">
<p className="text-neutral-500">No products match your filters.</p>
<p className="mt-2 text-sm text-neutral-400">
Try adjusting or clearing some filters to see more results.
</p>
</div>
)}
{/* Pagination */}
{totalPages > 1 && <ProductPagination currentPage={currentPage} totalPages={totalPages} />}
</main>
)
}
/**
* Generate human-readable labels for active filters
*/
function getActiveFilterLabels(
filters: ProductFiltersInput,
options: FILTER_OPTIONS_QUERY_RESULT,
): string[] {
const labels: string[] = []
if (filters.category?.length) {
const names = filters.category
.map((slug) => options.categories.find((c) => c.slug === slug)?.title)
.filter(Boolean)
if (names.length) labels.push(names.join(', '))
}
if (filters.brand?.length) {
const names = filters.brand
.map((slug) => options.brands.find((b) => b.slug === slug)?.title)
.filter(Boolean)
if (names.length) labels.push(names.join(', '))
}
if (filters.color?.length) {
const names = filters.color
.map((slug) => options.colors.find((c) => c.slug === slug)?.title)
.filter(Boolean)
if (names.length) labels.push(names.join(', '))
}
if (filters.size?.length) {
const codes = filters.size.filter((code) => options.sizes.some((s) => s.code === code))
if (codes.length) labels.push(`Size ${codes.join(', ')}`)
}
if (filters.maxPrice) {
labels.push(`Under $${filters.maxPrice}`)
}
return labels
}
function FilterBarSkeleton() {
return (
<div className="flex flex-wrap items-center gap-3">
{Array.from({length: 6}).map((_, i) => (
<div key={i} className="h-9 w-[140px] animate-pulse rounded-md bg-neutral-100" />
))}
</div>
)
}
'use client'
import {ShoppingBag} from 'lucide-react'
import {Button} from '@/components/ui/button'
interface AddToCartButtonProps {
disabled?: boolean
}
export function AddToCartButton({disabled}: AddToCartButtonProps) {
return (
<Button disabled={disabled} size="lg" className="w-full">
<ShoppingBag className="h-4 w-4" />
Add to Cart
</Button>
)
}
'use client'
import {MessageCircle, X} from 'lucide-react'
import {useState} from 'react'
import {Chat} from './chat'
export function ChatButton() {
const [isOpen, setIsOpen] = useState(false)
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end">
{/* Chat Window */}
<div
className={`
mb-4 h-[500px] w-[380px] transition-all duration-300 ease-out origin-bottom-right
${isOpen ? 'scale-100 opacity-100' : 'pointer-events-none scale-95 opacity-0'}
`}
>
<Chat onClose={() => setIsOpen(false)} />
</div>
{/* Toggle Button */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex h-14 w-14 items-center justify-center rounded-full bg-neutral-900 text-white shadow-lg transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl active:scale-95"
>
<div className="relative h-6 w-6">
<MessageCircle
className={`absolute inset-0 h-6 w-6 transition-all duration-300 ${isOpen ? 'rotate-90 scale-0 opacity-0' : 'rotate-0 scale-100 opacity-100'}`}
/>
<X
className={`absolute inset-0 h-6 w-6 transition-all duration-300 ${isOpen ? 'rotate-0 scale-100 opacity-100' : '-rotate-90 scale-0 opacity-0'}`}
/>
</div>
</button>
</div>
)
}
import {Send, Square} from 'lucide-react'
import {Button} from '@/components/ui/button'
interface ChatInputProps {
input: string
setInput: (value: string) => void
onSubmit: (e: React.FormEvent) => void
disabled: boolean
}
export function ChatInput(props: ChatInputProps) {
const {input, setInput, onSubmit, disabled} = props
return (
<form onSubmit={onSubmit} className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="How can I help?"
className="flex-1 rounded-md border border-neutral-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-neutral-900"
disabled={disabled}
/>
<Button type="submit" size="icon" disabled={disabled || !input.trim()}>
{disabled ? <Square className="h-3 w-3" /> : <Send className="h-4 w-4" />}
</Button>
</form>
)
}
'use client'
import {useChat} from '@ai-sdk/react'
import {DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls, type UIMessage} from 'ai'
import {MessageCircle, X} from 'lucide-react'
import {useRouter} from 'next/navigation'
import {useCallback, useEffect, useRef, useState} from 'react'
import {
AGENT_CHAT_HIDDEN_ATTRIBUTE,
captureScreenshot,
getDocumentContext,
getPageContent,
} from '@/lib/capture-context'
import {CLIENT_TOOL_NAMES, type ProductFiltersInput, productFiltersSchema} from '@/lib/client-tools'
import {ChatInput} from './chat-input'
import {Loader} from './loader'
import {Message} from './message/message'
/**
* Checks if the last message is waiting for text to be streamed.
* Used to show a loader when waiting for text.
*/
function isWaitingForText(messages: UIMessage[]): boolean {
const last = messages[messages.length - 1]
if (!last || last.role !== 'assistant') return true
const parts = last.parts ?? []
if (parts.length === 0) return true
const lastPart = parts[parts.length - 1]
return !(lastPart.type === 'text' && lastPart.text.trim().length > 0)
}
interface ChatProps {
onClose: () => void
}
export function Chat({onClose}: ChatProps) {
const router = useRouter()
const [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
// Queue for screenshot to send after tool output
const pendingScreenshotRef = useRef<string | null>(null)
// Apply product filters by navigating to /products with URL params
const applyProductFilters = useCallback(
(filters: ProductFiltersInput): string => {
const params = new URLSearchParams()
filters.category?.forEach((v) => params.append('category', v))
filters.color?.forEach((v) => params.append('color', v))
filters.size?.forEach((v) => params.append('size', v))
filters.brand?.forEach((v) => params.append('brand', v))
if (filters.minPrice) params.set('minPrice', String(filters.minPrice))
if (filters.maxPrice) params.set('maxPrice', String(filters.maxPrice))
if (filters.sort) params.set('sort', filters.sort)
const newUrl = `/products${params.toString() ? `?${params}` : ''}`
router.push(newUrl, {scroll: false})
return newUrl
},
[router],
)
const {messages, sendMessage, status, addToolOutput, error, regenerate} = useChat({
transport: new DefaultChatTransport({
body: () => ({documentContext: getDocumentContext()}),
}),
// Auto-continue for regular tools, but skip when screenshot is pending
// as we send the screenshot manually after the tool output is received.
sendAutomaticallyWhen: ({messages}) => {
if (pendingScreenshotRef.current) return false
return lastAssistantMessageIsCompleteWithToolCalls({messages})
},
onToolCall: async ({toolCall}) => {
if (toolCall.dynamic) return
const respond = (output: unknown): void => {
addToolOutput({tool: toolCall.toolName, toolCallId: toolCall.toolCallId, output})
}
switch (toolCall.toolName) {
case CLIENT_TOOL_NAMES.PAGE_CONTEXT: {
respond(getPageContent())
return
}
case CLIENT_TOOL_NAMES.SCREENSHOT: {
try {
pendingScreenshotRef.current = await captureScreenshot()
respond('Screenshot captured. It will arrive in the next message.')
} catch (err) {
respond(`Failed: ${err instanceof Error ? err.message : String(err)}`)
}
return
}
case CLIENT_TOOL_NAMES.SET_FILTERS: {
const parsed = productFiltersSchema.safeParse(toolCall.input)
if (!parsed.success) {
respond(`Invalid input: ${parsed.error.message}`)
return
}
const url = applyProductFilters(parsed.data)
respond(`Filters applied. Navigated to ${url}`)
}
}
},
})
// The `addToolOutput` does not support files, so we send the screenshot after
// the tool output is received and the status is ready as a follow-up message.
useEffect(() => {
if (status !== 'ready' || !pendingScreenshotRef.current) return
const screenshot = pendingScreenshotRef.current
pendingScreenshotRef.current = null
sendMessage({
files: [
{
type: 'file',
filename: 'screenshot.jpg',
mediaType: 'image/jpeg',
url: screenshot,
},
],
})
}, [status, sendMessage])
// Scroll to the bottom of the messages when new messages are added
useEffect(() => {
messagesEndRef.current?.scrollIntoView({behavior: 'smooth'})
}, [messages])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!input.trim()) return
sendMessage({text: input})
setInput('')
}
const isLoading = status === 'submitted' || status === 'streaming'
const showLoader = isLoading && isWaitingForText(messages)
return (
<div
{...{[AGENT_CHAT_HIDDEN_ATTRIBUTE]: 'true'}}
className="flex h-full w-full flex-col overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl"
>
{/* Header */}
<div className="flex items-center justify-between border-b border-neutral-100 bg-neutral-900 px-4 py-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10">
<MessageCircle className="h-4 w-4 text-white" />
</div>
<div>
<h3 className="text-sm font-medium text-white">Shopping Assistant</h3>
<p className="text-xs text-neutral-400">Ask me anything</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full p-1.5 text-neutral-400 transition-colors hover:bg-white/10 hover:text-white"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center text-center text-sm text-neutral-400">
<p>Ask me anything about our products.</p>
</div>
) : (
<div className="space-y-3">
{messages.map((message) => (
<Message key={message.id} message={message} />
))}
{showLoader && (
<div className="flex justify-start">
<div className="max-w-[80%] rounded-lg bg-neutral-100 px-4 py-2 text-sm text-neutral-900">
<Loader />
</div>
</div>
)}
{error && (
<div className="flex justify-start">
<div className="flex flex-col gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
<span>{error.message || 'Something went wrong.'}</span>
<button
type="button"
onClick={() => regenerate()}
className="w-fit rounded bg-red-600 px-3 py-1 text-xs font-medium text-white hover:bg-red-700"
>
Try again
</button>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<div className="border-t border-neutral-200 p-4">
<ChatInput input={input} setInput={setInput} onSubmit={handleSubmit} disabled={isLoading} />
</div>
</div>
)
}
import {Loader2} from 'lucide-react'
export function Loader() {
return (
<div className="flex items-center gap-2 text-xs text-neutral-500">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Thinking...</span>
</div>
)
}
import {Product} from './product'
interface DocumentProps {
id: string
type: string
isInline: boolean
}
/**
* Routes document directives to type-specific components.
*
* Flow: AI outputs directive -> remarkAgentDirectives parses -> this component routes by type
*
* Directive syntax (defined in route.ts system prompt):
* ::document{id="<_id>" type="<_type>"} - Block (cards in lists)
* :document{id="<_id>" type="<_type>"} - Inline (links in sentences)
*
* To add a new type: add a case here and create the component (see Product.tsx).
*/
export function Document(props: DocumentProps) {
const {id, type, isInline} = props
// During streaming, props may be incomplete - silently skip
if (!id) return null
if (type === 'product') {
return <Product id={id} isInline={isInline} />
}
return null
}
import {isTextUIPart, type UIMessage} from 'ai'
import {cn} from '@/lib/utils'
import {TextPart} from './text-part'
interface MessageProps {
message: UIMessage
}
export function Message(props: MessageProps) {
const {message} = props
const isUser = message.role === 'user'
const parts = message.parts ?? []
const content = parts.filter(isTextUIPart).filter((part) => part.text.trim())
if (content.length === 0) return null
return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[80%] space-y-2 rounded-lg px-4 py-3 text-sm',
isUser ? 'bg-neutral-900 text-white' : 'bg-neutral-100 text-neutral-900',
)}
>
{content.map((part, i) => {
return <TextPart key={i} text={part.text} isUser={isUser} />
})}
</div>
</div>
)
}
'use client'
import Image from 'next/image'
import Link from 'next/link'
import useSWR from 'swr'
import {client} from '@/sanity/lib/client'
import {urlFor} from '@/sanity/lib/image'
const QUERY = `
*[_type == "product" && _id == $id][0] {
title,
"slug": slug.current,
"image": variants[0].images[0],
}
`
interface ProductData {
slug: string
title: string
image: {asset: {_ref: string}} | null
}
interface ProductProps {
id: string
isInline?: boolean
}
export function Product(props: ProductProps) {
const {id, isInline} = props
const {data: product, isLoading} = useSWR(`product-${id}`, () =>
client.fetch<ProductData | null>(QUERY, {id}),
)
if (isLoading) {
if (isInline) return null
return (
<div className="flex animate-pulse items-center gap-3 rounded-md border border-neutral-200 bg-white p-2">
<div className="h-10 w-10 shrink-0 rounded bg-neutral-100" />
<div className="h-5 w-24 rounded bg-neutral-100" />
</div>
)
}
if (!product) return null
if (isInline) {
return (
<Link
href={`/products/${product.slug}`}
className="text-blue-600 underline hover:text-blue-700"
>
{product.title}
</Link>
)
}
return (
<Link
href={`/products/${product.slug}`}
className="flex items-center gap-3 rounded-md border border-neutral-200 bg-white p-2 transition-colors hover:border-neutral-300 hover:bg-neutral-50"
>
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded bg-neutral-100">
{product.image && (
<Image
src={urlFor(product.image).width(80).height(80).url()}
alt={product.title}
fill
className="object-cover"
/>
)}
</div>
<span className="text-sm font-medium text-neutral-900">{product.title}</span>
</Link>
)
}
import {remarkAgentDirectives} from '@sanity/agent-directives/react'
import Link from 'next/link'
import ReactMarkdown, {type Components} from 'react-markdown'
import {cn} from '@/lib/utils'
import {Document} from './document'
interface TextPartProps {
text: string
isUser: boolean
}
/**
* Wrapper for consecutive directives - renders children in a flex column
*/
function DirectivesStack({children}: {children?: React.ReactNode}) {
return <div className="flex flex-col gap-2">{children}</div>
}
type ExtendedComponents = Components & {
Document: typeof Document
DirectivesStack: typeof DirectivesStack
}
export function TextPart(props: TextPartProps) {
const {text, isUser} = props
if (!text.trim()) return null
const components: ExtendedComponents = {
Document,
DirectivesStack,
a(props) {
const {href = '', children} = props
const isInternal = href.startsWith('/')
const className = cn(
'underline',
isUser ? 'text-white/90 hover:text-white' : 'text-blue-600 hover:text-blue-700',
)
if (isInternal) {
return (
<Link href={href} className={className}>
{children}
</Link>
)
}
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
{children}
</a>
)
},
p: ({children}: {children?: React.ReactNode}) => (
<p className="whitespace-pre-wrap">{children}</p>
),
ul: ({children}: {children?: React.ReactNode}) => (
<ul className="list-disc pl-4">{children}</ul>
),
ol: (props) => <ol className="list-decimal pl-4">{props.children}</ol>,
}
return (
<ReactMarkdown remarkPlugins={[remarkAgentDirectives]} components={components}>
{text}
</ReactMarkdown>
)
}
'use client'
import {Check, ChevronDown, X} from 'lucide-react'
import {useRouter, useSearchParams} from 'next/navigation'
import {useCallback, useMemo, useRef, useState, useTransition} from 'react'
import {type ProductFiltersInput} from '@/lib/client-tools'
import {SORT_OPTIONS} from '@/sanity/queries/products'
import type {FILTER_OPTIONS_QUERY_RESULT} from '../../sanity.types'
import {Button} from './ui/button'
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from './ui/select'
interface FilterBarProps {
filterOptions: FILTER_OPTIONS_QUERY_RESULT
}
/**
* Parse URL param to array
*/
function getArrayParam(searchParams: URLSearchParams, key: string): string[] {
const values = searchParams.getAll(key)
return values.length > 0 ? values : []
}
export function FilterBar(props: FilterBarProps) {
const {filterOptions} = props
const router = useRouter()
const searchParams = useSearchParams()
const [isPending, startTransition] = useTransition()
// Read current filters from URL
const currentFilters = useMemo<ProductFiltersInput>(() => {
const sortParam = searchParams.get('sort')
return {
category: getArrayParam(searchParams, 'category'),
color: getArrayParam(searchParams, 'color'),
size: getArrayParam(searchParams, 'size'),
brand: getArrayParam(searchParams, 'brand'),
minPrice: searchParams.get('minPrice') ? Number(searchParams.get('minPrice')) : undefined,
maxPrice: searchParams.get('maxPrice') ? Number(searchParams.get('maxPrice')) : undefined,
sort: SORT_OPTIONS.find((s) => s.value === sortParam)?.value,
}
}, [searchParams])
// Update URL with new filters (merges with current)
const updateFilters = useCallback(
(updates: Partial<ProductFiltersInput>) => {
const merged = {...currentFilters, ...updates}
const params = new URLSearchParams()
// Array params
const arrayKeys = ['category', 'color', 'size', 'brand'] as const
for (const key of arrayKeys) {
merged[key]?.forEach((v) => params.append(key, v))
}
// Single-value params
if (merged.minPrice) params.set('minPrice', String(merged.minPrice))
if (merged.maxPrice) params.set('maxPrice', String(merged.maxPrice))
if (merged.sort) params.set('sort', merged.sort)
startTransition(() => {
const query = params.toString()
router.push(query ? `/products?${query}` : '/products', {scroll: false})
})
},
[router, currentFilters],
)
// Toggle a value in an array filter
const toggleArrayFilter = useCallback(
(key: 'category' | 'color' | 'size' | 'brand', value: string) => {
const current = currentFilters[key] || []
const newValues = current.includes(value)
? current.filter((v) => v !== value)
: [...current, value]
updateFilters({[key]: newValues})
},
[currentFilters, updateFilters],
)
// Clear a single filter
const clearFilter = useCallback(
(key: keyof ProductFiltersInput, value?: string) => {
const filterValue = currentFilters[key]
if (value && Array.isArray(filterValue)) {
// Remove single value from array
updateFilters({[key]: filterValue.filter((v) => v !== value)})
} else {
// Clear entire filter
updateFilters({[key]: undefined})
}
},
[currentFilters, updateFilters],
)
// Clear all filters
const clearAllFilters = useCallback(() => {
startTransition(() => {
router.push('/products', {scroll: false})
})
}, [router])
// Generate price range options
const priceRanges = generatePriceRanges(filterOptions.priceRange)
// Build active filter chips (individual items for arrays)
const activeFilters = buildActiveFilters(currentFilters, filterOptions, priceRanges)
return (
<div className={`space-y-3 ${isPending ? 'pointer-events-none opacity-70' : ''}`}>
{/* Filter dropdowns */}
<div className="flex flex-wrap items-center gap-3">
{/* Category - Multi-select */}
<MultiSelectDropdown
label="Category"
selected={currentFilters.category || []}
options={filterOptions.categories
.filter((cat) => cat.slug)
.map((cat) => ({value: cat.slug!, label: cat.title || cat.slug!}))}
onToggle={(value) => toggleArrayFilter('category', value)}
/>
{/* Color - Multi-select */}
<MultiSelectDropdown
label="Color"
selected={currentFilters.color || []}
options={filterOptions.colors
.filter((color) => color.slug)
.map((color) => ({
value: color.slug!,
label: color.title || color.slug!,
color: color.hexValue || undefined,
}))}
onToggle={(value) => toggleArrayFilter('color', value)}
/>
{/* Size - Multi-select */}
<MultiSelectDropdown
label="Size"
selected={currentFilters.size || []}
options={filterOptions.sizes
.filter((size) => size.code)
.map((size) => ({value: size.code!, label: size.code!}))}
onToggle={(value) => toggleArrayFilter('size', value)}
width="w-[120px]"
/>
{/* Brand - Multi-select */}
<MultiSelectDropdown
label="Brand"
selected={currentFilters.brand || []}
options={filterOptions.brands
.filter((brand) => brand.slug)
.map((brand) => ({value: brand.slug!, label: brand.title || brand.slug!}))}
onToggle={(value) => toggleArrayFilter('brand', value)}
/>
{/* Price - Single select */}
<Select
value={currentFilters.maxPrice?.toString() || ''}
onValueChange={(value) => updateFilters({maxPrice: value ? Number(value) : undefined})}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Price" />
</SelectTrigger>
<SelectContent>
{priceRanges.map((range) => (
<SelectItem key={range.value} value={range.value.toString()}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Sort - Single select */}
<Select
value={currentFilters.sort || ''}
onValueChange={(value) => {
const validSort = SORT_OPTIONS.find((s) => s.value === value)
updateFilters({sort: validSort?.value})
}}
>
<SelectTrigger className="w-[170px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Active filter chips */}
{activeFilters.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{activeFilters.map((filter) => (
<button
key={`${filter.key}-${filter.value || 'all'}`}
type="button"
onClick={() => clearFilter(filter.key, filter.value)}
className="inline-flex items-center gap-1.5 rounded-full bg-neutral-100 px-3 py-1 text-sm text-neutral-700 transition-colors hover:bg-neutral-200"
>
{filter.label}
<X className="h-3.5 w-3.5" />
</button>
))}
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="text-neutral-500 hover:text-neutral-700"
>
Clear all
</Button>
</div>
)}
</div>
)
}
// Multi-select dropdown component
interface MultiSelectOption {
value: string
label: string
color?: string
}
interface MultiSelectDropdownProps {
label: string
selected: string[]
options: MultiSelectOption[]
onToggle: (value: string) => void
width?: string
}
function MultiSelectDropdown(props: MultiSelectDropdownProps) {
const {label, selected, options, onToggle, width = 'w-[150px]'} = props
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const displayText = selected.length > 0 ? `${label} (${selected.length})` : label
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={`flex h-9 ${width} items-center justify-between gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm shadow-sm transition-colors hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2`}
>
<span className={selected.length > 0 ? 'text-neutral-900' : 'text-neutral-500'}>
{displayText}
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</button>
{isOpen && (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
{/* Dropdown */}
<div className="absolute left-0 top-full z-50 mt-1 max-h-60 min-w-[180px] overflow-auto rounded-md border border-neutral-200 bg-white py-1 shadow-lg">
{options.map((option) => {
const isSelected = selected.includes(option.value)
return (
<button
key={option.value}
type="button"
onClick={() => {
onToggle(option.value)
setIsOpen(false)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-neutral-100"
>
<span
className={`flex h-4 w-4 items-center justify-center rounded border ${isSelected ? 'border-neutral-900 bg-neutral-900 text-white' : 'border-neutral-300'}`}
>
{isSelected && <Check className="h-3 w-3" />}
</span>
{option.color && (
<span
className="h-3 w-3 rounded-full border border-neutral-300"
style={{backgroundColor: option.color}}
/>
)}
<span>{option.label}</span>
</button>
)
})}
</div>
</>
)}
</div>
)
}
interface ActiveFilter {
key: keyof ProductFiltersInput
value?: string // For array filters, the specific value
label: string
}
function buildActiveFilters(
filters: ProductFiltersInput,
options: FILTER_OPTIONS_QUERY_RESULT,
priceRanges: Array<{value: number; label: string}>,
): ActiveFilter[] {
const active: ActiveFilter[] = []
// Category chips (one per selected category)
filters.category?.forEach((slug) => {
const cat = options.categories.find((c) => c.slug === slug)
if (cat?.title) active.push({key: 'category', value: slug, label: cat.title})
})
// Color chips
filters.color?.forEach((slug) => {
const color = options.colors.find((c) => c.slug === slug)
if (color?.title) active.push({key: 'color', value: slug, label: color.title})
})
// Size chips
filters.size?.forEach((code) => {
const size = options.sizes.find((s) => s.code === code)
if (size?.code) active.push({key: 'size', value: code, label: `Size ${size.code}`})
})
// Brand chips
filters.brand?.forEach((slug) => {
const brand = options.brands.find((b) => b.slug === slug)
if (brand?.title) active.push({key: 'brand', value: slug, label: brand.title})
})
// Price chip
if (filters.maxPrice) {
const range = priceRanges.find((r) => r.value === filters.maxPrice)
active.push({key: 'maxPrice', label: range?.label || `Under $${filters.maxPrice}`})
}
// Sort chip
if (filters.sort) {
const sortOption = SORT_OPTIONS.find((s) => s.value === filters.sort)
if (sortOption) active.push({key: 'sort', label: sortOption.label})
}
return active
}
function generatePriceRanges(priceRange: FILTER_OPTIONS_QUERY_RESULT['priceRange']): Array<{
value: number
label: string
}> {
const max = priceRange.max ?? 500
const thresholds = [50, 100, 150, 200, 300, 500, 1000].filter((t) => t <= max * 1.5)
return thresholds.map((value) => ({
value,
label: `Under $${value}`,
}))
}
import {ShoppingBag} from 'lucide-react'
import Link from 'next/link'
export function Header() {
return (
<header className="border-b border-neutral-200 bg-white">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
<Link href="/" className="text-xl font-semibold tracking-tight">
Store
</Link>
<nav className="flex items-center gap-6">
<Link href="/products" className="text-sm text-neutral-600 hover:text-neutral-900">
Products
</Link>
<button aria-label="Cart" type="button">
<ShoppingBag className="h-5 w-5" />
</button>
</nav>
</div>
</header>
)
}
import Image from 'next/image'
import Link from 'next/link'
import {Badge} from '@/components/ui/badge'
import {formatPrice} from '@/lib/utils'
import {urlFor} from '@/sanity/lib/image'
import type {PRODUCTS_QUERY_RESULT} from '../../sanity.types'
type Product = PRODUCTS_QUERY_RESULT[number]
interface ProductCardProps {
product: Product
}
export function ProductCard({product}: ProductCardProps) {
const {title, slug, image, price, category, brand} = product
const hasDiscount = price?.compareAtPrice && price.compareAtPrice > (price.amount ?? 0)
return (
<Link href={`/products/${slug}`} className="group block">
<div className="relative aspect-[3/4] overflow-hidden rounded-lg bg-neutral-100">
{image?.asset?.url ? (
<Image
src={urlFor(image).width(600).height(800).url()}
alt={image.alt || title || 'Product image'}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
placeholder={image.asset.metadata?.lqip ? 'blur' : 'empty'}
blurDataURL={image.asset.metadata?.lqip || undefined}
/>
) : (
<div className="flex h-full items-center justify-center text-neutral-400">No image</div>
)}
{hasDiscount && (
<Badge className="absolute left-2 top-2" variant="destructive">
Sale
</Badge>
)}
</div>
<div className="mt-3 space-y-1">
{(brand?.title || category?.title) && (
<p className="text-xs text-neutral-500">
{brand?.title}
{brand?.title && category?.title && ' · '}
{category?.title}
</p>
)}
<h3 className="text-sm font-medium text-neutral-900 group-hover:underline">{title}</h3>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{formatPrice(price?.amount)}</span>
{hasDiscount && (
<span className="text-sm text-neutral-500 line-through">
{formatPrice(price?.compareAtPrice)}
</span>
)}
</div>
</div>
</Link>
)
}
import type {PRODUCTS_QUERY_RESULT} from '../../sanity.types'
import {ProductCard} from './product-card'
interface ProductGridProps {
products: PRODUCTS_QUERY_RESULT
}
export function ProductGrid({products}: ProductGridProps) {
if (!products.length) {
return <div className="py-12 text-center text-neutral-500">No products found.</div>
}
return (
<div className="grid grid-cols-2 gap-4 sm:gap-6 md:grid-cols-3 lg:grid-cols-4">
{products.map((product) => (
<ProductCard key={product._id} product={product} />
))}
</div>
)
}
'use client'
import {useSearchParams} from 'next/navigation'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
interface ProductPaginationProps {
currentPage: number
totalPages: number
}
export function ProductPagination(props: ProductPaginationProps) {
const {currentPage, totalPages} = props
const searchParams = useSearchParams()
// Preserve existing filter params when navigating pages
const createPageURL = (page: number) => {
const params = new URLSearchParams(searchParams.toString())
params.set('page', String(page))
return `/products?${params.toString()}`
}
// Generate page numbers to display (current +/- 1, plus first/last)
const getPageNumbers = (): (number | 'ellipsis')[] => {
const pages: (number | 'ellipsis')[] = []
if (totalPages <= 5) {
return Array.from({length: totalPages}, (_, i) => i + 1)
}
pages.push(1)
if (currentPage > 3) pages.push('ellipsis')
for (
let i = Math.max(2, currentPage - 1);
i <= Math.min(totalPages - 1, currentPage + 1);
i++
) {
pages.push(i)
}
if (currentPage < totalPages - 2) pages.push('ellipsis')
pages.push(totalPages)
return pages
}
return (
<Pagination className="mt-8">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={currentPage > 1 ? createPageURL(currentPage - 1) : undefined}
aria-disabled={currentPage <= 1}
className={currentPage <= 1 ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
{getPageNumbers().map((page, i) =>
page === 'ellipsis' ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={page}>
<PaginationLink href={createPageURL(page)} isActive={page === currentPage}>
{page}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext
href={currentPage < totalPages ? createPageURL(currentPage + 1) : undefined}
aria-disabled={currentPage >= totalPages}
className={currentPage >= totalPages ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}
import {cva, type VariantProps} from 'class-variance-authority'
import type * as React from 'react'
import {cn} from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-neutral-900 text-neutral-50',
secondary: 'border-transparent bg-neutral-100 text-neutral-900',
outline: 'text-neutral-950',
destructive: 'border-transparent bg-red-500 text-neutral-50',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({className, variant, ...props}: BadgeProps) {
return <div className={cn(badgeVariants({variant}), className)} {...props} />
}
export {Badge, badgeVariants}
import {Slot} from '@radix-ui/react-slot'
import {cva, type VariantProps} from 'class-variance-authority'
import * as React from 'react'
import {cn} from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({className, variant, size, asChild = false, ...props}, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({variant, size, className}))} ref={ref} {...props} />
},
)
Button.displayName = 'Button'
export {Button, buttonVariants}
// Re-export all queries for convenient imports
export * from './categories'
export * from './filters'
export * from './products'