Sanity Embeddings: Semantic Search in a Next.js App Router Site
By Nayan Kyada · · 6 min read
Part of The Sanity + Next.js Guide
Sanity Embeddings semantic search in Next.js is one of those features that looks complicated from the outside but is surprisingly lean to wire up once you understand the moving parts. This post covers the current native Embeddings feature built into Sanity datasets — not the older Embeddings Index API, which Sanity is sunsetting. If you found a guide that talks about a separate embeddings-index resource you have to provision via the Management API, it is stale; skip it.
What Sanity Embeddings actually is
Sanity's native Embeddings feature lets you mark document types for vector indexing directly inside your dataset. Sanity handles the embedding model and the vector store; you never manage a separate service. Queries use a dedicated sanity.embeddings.query GROQ function that takes a natural-language string and returns documents ranked by semantic similarity. The feature is available on Growth and Enterprise plans as of mid-2026.
The workflow has three parts:
- Configure which document types get indexed (dataset setting or the Embeddings pane in Sanity Studio).
- Run a semantic query from your Next.js route handler using the Sanity client.
- Render the results in a search UI component.
Setting up the embeddings index in your dataset
Go to Manage → your project → Embeddings (or open the Embeddings pane inside Sanity Studio if your plan surfaces it there). Create an index, give it a name (e.g. site_search), and select which document types and fields to embed. For a blog you would typically pick post with fields title, excerpt, and body (plain text extracted from Portable Text).
Sanity backfills existing documents automatically. New and updated documents are re-embedded on publish via an internal webhook — you do not configure that yourself.
There is no code required for the indexing step. The index name you choose here (site_search) is what you will pass in the GROQ query.
Querying embeddings from a Next.js route handler
Create a route handler that accepts a search term, fires the semantic query against Sanity, and returns JSON. Using a server-side route handler keeps your Sanity read token out of the browser.
// app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@sanity/client';
const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-09-01',
useCdn: false,
token: process.env.SANITY_API_READ_TOKEN,
});
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q')?.trim();
if (!q) return NextResponse.json({ results: [] });
// sanity::embeddings.query is the GROQ function for native semantic search.
// 'site_search' must match the index name you created in the Embeddings pane.
const results = await client.fetch(
`*[sanity::embeddings.query('site_search', $query, 4)] {
_id,
_type,
title,
"slug": slug.current,
excerpt
}`,
{ query: q }
);
return NextResponse.json({ results });
}A few things worth noting here. The fourth argument to sanity::embeddings.query is the maximum number of results — keep it small (4–8) to stay within rate limits and keep the UI fast. useCdn: false is required because the CDN does not serve personalised query results. The read token is needed because embeddings queries run against the non-public API surface.
If you are on a plan that does not include Embeddings, the GROQ function will throw; you will see a clear error in the logs rather than silent empty results.
Building the search UI in App Router
The UI is a client component so the user can type without a full page reload. It calls the route handler with a debounced fetch and renders results as they arrive.
// components/SemanticSearch.tsx
'use client';
import { useRef, useState, useTransition } from 'react';
type Result = {
_id: string;
_type: string;
title: string;
slug: string;
excerpt?: string;
};
export function SemanticSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
const [isPending, startTransition] = useTransition();
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
function handleChange(value: string) {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (value.length < 3) { setResults([]); return; }
// Debounce the embeddings fetch — it's a slower, non-cacheable query,
// so firing it on every keystroke would hammer the API needlessly.
debounceRef.current = setTimeout(() => {
startTransition(async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(value)}`);
const data = await res.json();
setResults(data.results ?? []);
});
}, 300);
}
return (
<div className="w-full max-w-xl">
<input
type="search"
value={query}
onChange={(e) => handleChange(e.target.value)}
placeholder="Search articles…"
className="w-full rounded-lg border border-neutral-300 px-4 py-2 text-sm"
/>
{isPending && <p className="mt-2 text-xs text-neutral-400">Searching…</p>}
{results.length > 0 && (
<ul className="mt-3 divide-y divide-neutral-100 rounded-lg border border-neutral-200">
{results.map((r) => (
<li key={r._id} className="px-4 py-3">
<a href={`/blog/${r.slug}`} className="font-medium text-neutral-900 hover:underline">
{r.title}
</a>
{r.excerpt && <p className="mt-0.5 text-xs text-neutral-500 line-clamp-2">{r.excerpt}</p>}
</li>
))}
</ul>
)}
</div>
);
}Drop <SemanticSearch /> into any server component page. Because the component is 'use client', the surrounding page stays static and the search widget hydrates independently.
Where semantic beats keyword search
Keyword search (including Algolia) matches on tokens. If a user types "how to improve site speed" and your article is titled "optimising Core Web Vitals", a keyword index will likely miss it. The embeddings query will surface it because the underlying vectors capture meaning, not just character sequences.
The trade-off is latency. A GROQ embeddings query takes 200–500 ms in my testing, compared to ~30 ms for a plain GROQ filter. That's why the component above debounces 300 ms before firing the fetch — searching on every keystroke would hammer the API for no benefit. The useTransition marks the fetch as non-urgent on top of that, so React will not block the input from updating while results load.
Caching and rate limits
Sanity does not allow CDN caching for embeddings queries, so every request hits the origin. On Growth plans there is a documented rate limit per minute — check the current limits in the Sanity dashboard before going to production. For sites with high search volume, a short-lived cache layer (Redis or Vercel KV, keyed by the query string) can take the pressure off. A 60-second TTL on popular queries is usually enough to flatten spikes without serving stale results.
Do not cache in the Next.js route handler with revalidate — the same cache key would be shared across users and the route has no user-specific data to protect, but query diversity makes server-side caching wasteful unless you observe repeated identical queries in your logs first.
Frequently asked questions
01What is Sanity's native Embeddings feature, and is the old Embeddings Index API still relevant?
Native Embeddings lets you mark document types for vector indexing directly inside your dataset — Sanity handles the embedding model and vector store, queried via the `sanity::embeddings.query` GROQ function. The older, separately-provisioned Embeddings Index API is being sunset; if a guide describes provisioning a separate `embeddings-index` resource via the Management API, it's describing the deprecated approach.
02Why does a Sanity embeddings search feel slower than a regular GROQ filter?
Because it's doing more work — a semantic query takes roughly 200-500ms versus ~30ms for a plain filter, and results can't be served from Sanity's CDN since embeddings queries aren't cacheable there. Debounce the search input (300ms is a reasonable default) and consider a short-lived cache layer like Redis or Vercel KV for popular queries if search volume is high.