How to Add Semantic Search to Your App with Cosmic

Cosmic AI
July 14, 2026
Keyword search has a ceiling. A user types "pricing for small teams" and your search returns nothing, because your pricing page says "plans for startups." The words don't match even though the meaning does.
Cosmic's new semantic search feature solves this at the infrastructure layer. Your content is automatically indexed as vector embeddings when you write or update it. At query time, you send a natural-language string and get back the objects whose meaning is closest to your query, ranked by a relevance score. No extra infrastructure, no separate vector database, no embedding pipeline to maintain.
This tutorial walks you through enabling the feature, making your first search call, filtering results, and wiring it into a real UI.
Beta note: Semantic search is currently in beta. Embeddings are generated for free. Queries consume AI tokens from your plan's allocation.
Prerequisites
- A Cosmic account and bucket (sign up free)
- Your bucket slug and write key (semantic search requires the write key, not just the read key)
- Node.js 18+ if following the JS examples
Step 1: Enable Semantic Search
In your Cosmic dashboard, go to Project Settings → Semantic Search and toggle the feature on. New projects have it enabled by default.
Once enabled, Cosmic begins generating embeddings for your existing content in the background. For large buckets this may take a few minutes. Any new or updated objects are indexed immediately on write.
Step 2: Your First Semantic Search Call
The endpoint lives at the Cosmic workers layer:
POST https://workers.cosmicjs.com/v3/buckets/{bucket_slug}/ai/search
It requires your write key in the Authorization header.
Request
const response = await fetch( `https://workers.cosmicjs.com/v3/buckets/${bucketSlug}/ai/search`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${writeKey}`, }, body: JSON.stringify({ query: 'plans for small teams', limit: 5, }), } ); const { results } = await response.json(); console.log(results);
Response shape
{ "results": [ { "object_id": "abc123", "slug": "pricing-startups", "type": "pages", "locale": "en", "status": "published", "score": 0.83, "snippet": "Annual plans can be refunded within 30 days..." }, { "object_id": "def456", "slug": "team-plan", "type": "pages", "locale": "en", "status": "published", "score": 0.74, "snippet": "Our Team plan is designed for growing startups..." } ] }
The score field is a cosine similarity value between 0 and 1. Higher scores mean closer semantic match. The scores in this example are illustrative output, not guaranteed minimums.
Note that the response returns lightweight references (object_id, slug, type, locale, status, score, snippet), not full Cosmic objects. To get full content for display, you'll need a follow-up SDK call (see Step 3).
Step 3: Fetch Full Object Data After Search
The search response returns object_id values and scores. To get full content for display, fetch the matched objects using the @cosmicjs/sdk:
import { createBucketClient } from '@cosmicjs/sdk'; const cosmic = createBucketClient({ bucketSlug: process.env.COSMIC_BUCKET_SLUG as string, readKey: process.env.COSMIC_READ_KEY as string, }); async function semanticSearch(query: string) { // Step 1: Get ranked results from semantic search const searchRes = await fetch( `https://workers.cosmicjs.com/v3/buckets/${process.env.COSMIC_BUCKET_SLUG}/ai/search`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.COSMIC_WRITE_KEY}`, }, body: JSON.stringify({ query, limit: 5 }), } ); const { results } = await searchRes.json(); const ids = results.map((r: { object_id: string }) => r.object_id); if (ids.length === 0) return []; // Step 2: Fetch full objects by ID const { objects } = await cosmic.objects .find({ id: { $in: ids } }) .props('id,title,slug,metadata'); // Step 3: Re-sort by semantic score (SDK results come back in DB order) const scoreMap = Object.fromEntries( results.map((r: { object_id: string; score: number }) => [r.object_id, r.score]) ); return objects.sort((a, b) => scoreMap[b.id] - scoreMap[a.id]); }
Note on the write key: Keep
COSMIC_WRITE_KEYin a server-side environment variable only. Never expose it in client-side code or a public bundle.
Step 4: Filter by Object Type, Status, or Locale
You can scope results to a specific content type or status to avoid surfacing draft content or unrelated types:
body: JSON.stringify({ query: 'getting started with the API', limit: 10, type: 'docs', // only search within this object type status: 'published', // 'published' | 'draft' | 'any' locale: 'en', // optional locale filter min_score: 0.7, // only return results above this threshold }),
Useful combinations:
- Support search box:
type: 'help-articles', status: 'published' - Internal knowledge base:
type: 'internal-docs', status: 'any' - Localized product search:
type: 'products', locale: 'de'
Step 5: Build a Search UI in Next.js
Here's a minimal server action + component pattern for a Next.js App Router project:
// app/actions/search.ts 'use server'; export async function searchContent(query: string) { if (!query || query.trim().length < 2) return []; const res = await fetch( `https://workers.cosmicjs.com/v3/buckets/${process.env.COSMIC_BUCKET_SLUG}/ai/search`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.COSMIC_WRITE_KEY}`, }, body: JSON.stringify({ query, limit: 8, status: 'published', min_score: 0.65, }), cache: 'no-store', } ); if (!res.ok) return []; const { results } = await res.json(); return results; }
// components/SearchBox.tsx 'use client'; import { useState, useTransition } from 'react'; import { searchContent } from '@/app/actions/search'; export function SearchBox() { const [query, setQuery] = useState(''); const [results, setResults] = useState<any[]>([]); const [isPending, startTransition] = useTransition(); function handleSearch(value: string) { setQuery(value); if (value.length < 3) { setResults([]); return; } startTransition(async () => { const hits = await searchContent(value); setResults(hits); }); } return ( <div> <input type="search" value={query} onChange={(e) => handleSearch(e.target.value)} placeholder="Search documentation..." /> {isPending && <p>Searching...</p>} <ul> {results.map((r) => ( <li key={r.object_id}> <a href={`/blog/${r.slug}`}> {r.snippet} <span>({Math.round(r.score * 100)}% match)</span> </a> </li> ))} </ul> </div> ); }
Step 6: Semantic Search vs Structured Queries
Semantic search and Cosmic's standard structured queries solve different problems. Use the right tool for each:
Use semantic search when:
- Users type natural-language questions or descriptions
- You want fuzzy, intent-based matching across your entire content library
- The query words won't necessarily appear verbatim in the content
Use structured queries when:
- You're filtering by a known metadata value (
metadata.status: 'featured') - You need exact-match lookups (by slug, ID, or tag)
- You're paginating a known content type
The two approaches compose well: use semantic search to find candidate object_id values, then a structured objects.find({ id: { $in: ids } }) call to fetch and filter the full objects.
Docs: Cosmic REST API queries | JavaScript/TypeScript SDK
Step 7: Power an AI Agent with Semantic Search
Semantic search pairs naturally with the Cosmic MCP server. When an AI agent (Claude, GPT, Cursor, etc.) connects to your Cosmic bucket via MCP, it can call search_content with a natural-language query to retrieve relevant objects before generating a response. This is the RAG pattern: Retrieval-Augmented Generation using your own CMS content as the knowledge base.
See the Cosmic MCP server docs to wire this up.
What's Next
Semantic search is live in beta for all Cosmic buckets. Here's what to do next:
- Enable it in Project Settings → Semantic Search
- Try a query against your existing content using the
curlexample from the docs - Wire it into your app following the patterns above
- Book a demo if you want a walkthrough with your specific content model







