{"slug": "how-i-add-semantic-search-to-a-next-js-site-using-sanity-embeddings", "title": "How I add semantic search to a Next.js site using Sanity Embeddings", "summary": "Sanity's native Embeddings feature enables semantic search in Next.js without managing external vector stores. A developer demonstrates wiring up the feature by creating an index in Sanity Studio, then querying it via a GROQ function in a Next.js API route. The approach requires a Growth or Enterprise plan and a read token, but eliminates the need for separate embedding services.", "body_md": "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`\n\nresource you have to provision via the Management API, it is stale; skip it.\n\nSanity'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`\n\nGROQ 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.\n\nThe workflow has three parts:\n\nGo 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`\n\n), and select which document types and fields to embed. For a blog you would typically pick `post`\n\nwith fields `title`\n\n, `excerpt`\n\n, and `body`\n\n(plain text extracted from Portable Text).\n\nSanity backfills existing documents automatically. New and updated documents are re-embedded on publish via an internal webhook — you do not configure that yourself.\n\nThere is no code required for the indexing step. The index name you choose here (`site_search`\n\n) is what you will pass in the GROQ query.\n\nCreate 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.\n\n``` js\n// app/api/search/route.ts\nimport { NextRequest, NextResponse } from 'next/server';\nimport { createClient } from '@sanity/client';\n\nconst client = createClient({\n  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,\n  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,\n  apiVersion: '2024-09-01',\n  useCdn: false,\n  token: process.env.SANITY_API_READ_TOKEN,\n});\n\nexport async function GET(req: NextRequest) {\n  const q = req.nextUrl.searchParams.get('q')?.trim();\n  if (!q) return NextResponse.json({ results: [] });\n\n  // sanity::embeddings.query is the GROQ function for native semantic search.\n  // 'site_search' must match the index name you created in the Embeddings pane.\n  const results = await client.fetch(\n    `*[sanity::embeddings.query('site_search', $query, 4)] {\n      _id,\n      _type,\n      title,\n      \"slug\": slug.current,\n      excerpt\n    }`,\n    { query: q }\n  );\n\n  return NextResponse.json({ results });\n}\n```\n\nA few things worth noting here. The fourth argument to `sanity::embeddings.query`\n\nis the maximum number of results — keep it small (4–8) to stay within rate limits and keep the UI fast. `useCdn: false`\n\nis 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.\n\nIf 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.\n\nThe 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.\n\n``` js\n// components/SemanticSearch.tsx\n'use client';\n\nimport { useState, useTransition } from 'react';\n\ntype Result = {\n  _id: string;\n  _type: string;\n  title: string;\n  slug: string;\n  excerpt?: string;\n};\n\nexport function SemanticSearch() {\n  const [query, setQuery] = useState('');\n  const [results, setResults] = useState<Result[]>([]);\n  const [isPending, startTransition] = useTransition();\n\n  async function handleChange(value: string) {\n    setQuery(value);\n    if (value.length < 3) { setResults([]); return; }\n\n    startTransition(async () => {\n      const res = await fetch(`/api/search?q=${encodeURIComponent(value)}`);\n      const data = await res.json();\n      setResults(data.results ?? []);\n    });\n  }\n\n  return (\n    <div className=\"w-full max-w-xl\">\n      <input\n        type=\"search\"\n        value={query}\n        onChange={(e) => handleChange(e.target.value)}\n        placeholder=\"Search articles…\"\n        className=\"w-full rounded-lg border border-neutral-300 px-4 py-2 text-sm\"\n      />\n      {isPending && <p className=\"mt-2 text-xs text-neutral-400\">Searching…</p>}\n      {results.length > 0 && (\n        <ul className=\"mt-3 divide-y divide-neutral-100 rounded-lg border border-neutral-200\">\n          {results.map((r) => (\n            <li key={r._id} className=\"px-4 py-3\">\n              <a href={`/blog/${r.slug}`} className=\"font-medium text-neutral-900 hover:underline\">\n                {r.title}\n              </a>\n              {r.excerpt && <p className=\"mt-0.5 text-xs text-neutral-500 line-clamp-2\">{r.excerpt}</p>}\n            </li>\n          ))}\n        </ul>\n      )}\n    </div>\n  );\n}\n```\n\nDrop `<SemanticSearch />`\n\ninto any server component page. Because the component is `'use client'`\n\n, the surrounding page stays static and the search widget hydrates independently.\n\nKeyword 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.\n\nThe 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`\n\nin the component above marks the fetch as non-urgent, so React will not block the input from updating while results load.\n\nSanity 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.\n\nDo not cache in the Next.js route handler with `revalidate`\n\n— 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.", "url": "https://wpnews.pro/news/how-i-add-semantic-search-to-a-next-js-site-using-sanity-embeddings", "canonical_source": "https://dev.to/nayankyada/how-i-add-semantic-search-to-a-nextjs-site-using-sanity-embeddings-1pho", "published_at": "2026-07-08 06:44:21+00:00", "updated_at": "2026-07-08 06:58:22.720904+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "natural-language-processing", "ai-products"], "entities": ["Sanity", "Next.js", "GROQ", "Sanity Embeddings", "Sanity Studio"], "alternates": {"html": "https://wpnews.pro/news/how-i-add-semantic-search-to-a-next-js-site-using-sanity-embeddings", "markdown": "https://wpnews.pro/news/how-i-add-semantic-search-to-a-next-js-site-using-sanity-embeddings.md", "text": "https://wpnews.pro/news/how-i-add-semantic-search-to-a-next-js-site-using-sanity-embeddings.txt", "jsonld": "https://wpnews.pro/news/how-i-add-semantic-search-to-a-next-js-site-using-sanity-embeddings.jsonld"}}