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 { 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();
async function handleChange(value: string) {
setQuery(value);
if (value.length < 3) { setResults([]); return; }
startTransition(async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(value)}`);
const data = await res.json();
setResults(data.results ?? []);
});
}
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. For a search box that triggers on keystroke, add a 300 ms debounce before firing the fetch to avoid hammering the API. The useTransition in the component above marks the fetch as non-urgent, 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.
Top comments (0)