If Users Can't Find Anything, They'll Assume Your Product Is Empty: A Field Guide to In-Site Search for Vibe-Coded Projects
AI-generated project scaffolds ship with no search, and users who can't find anything assume the product is empty. This guide covers a three-layer mental model, a Postgres tsvector vs. Typesense comparison with runnable code, and the details that matter: debouncing, highlighting, empty states, typo tolerance, plus a launch checklist.

If Users Can't Search It, They'll Think Your Product Is Empty
I've watched too many vibe-coded projects die in the exact same spot: the author pulls several all-nighters, stuffs in hundreds of content items and dozens of features, launches, and the first thing a new user does is click the search box, type a few characters, hit enter, see "no results found," and leave.
The problem isn't the content. The problem is that users never reach the content. The CRUD skeleton your AI coding tool generated ships with no search by default — not bad search, no search at all. The list pages that Cursor, Claude Code, and friends generate for you generally know two tricks: "load everything" and "sort by creation date descending." Once you pass a hundred items, the page becomes a dry well: everything sits at the bottom, and users can't draw it up.
The psychology here is brutal: users won't think "this product's search is weak" — they'll think "this product has nothing in it." The search box is a user's first probe of your product's content density. If search works, the product feels deep; if it doesn't, the product feels empty. A month of accumulated content can be sentenced to death by a missing search box.
The good news: adding "good enough and genuinely pleasant" in-site search to a solo project is honestly a weekend of work. You don't need to become a search engine expert. You need a three-layer mental model, one correct architecture decision, and a handful of experience details done right. This guide is the complete field manual for exactly that.
One Question First: Is Your Content Worth Searching?
Before writing any code, a splash of cold water. Search solves the "finding" problem, but it has a prerequisite: there has to be something worth finding in the first place. If your project has 20 items, a search box is decoration — a list users can scan at a glance doesn't need search. Spending time on search at that stage is classic premature optimization.
The rule of thumb: past 100 items, or when returning users are clearly "looking for" something rather than "browsing," it's time to add search. A hundred is a magic number: below it, a few scrolls reach the bottom; above it, users start relying on memory and luck, and luck is the least reliable navigation.
There's also a counterintuitive dynamic: the search box itself changes user behavior. With good search, users switch from "browse mode" to "retrieval mode" — they stop patiently flipping through your categories and recommendations and just demand answers. That means your content titles have to change after search ships: titles should contain the words users will actually search, not the words you find poetic. "My Ramen Journey" loses to "3-Day Tokyo Ramen Itinerary" every time. Search isn't just a feature; it reshapes how you produce content.
The Mental Model: Search Is Only Three Layers
Break the big scary word "site search" apart and it's really just three layers, each solving one problem:
- Indexing: turning content into a structure that can be searched quickly. The database stores whole blobs of text; search needs an inverted mapping of "which word appears in which record." Without an index, every search is a full-table LIKE scan — unusable past ten thousand rows.
- Querying: turning user input into an effective lookup. That includes tokenization (in Chinese you must decide whether "ramen" is one token or pieces), typo tolerance (what if the user mistypes?), and weighting (should a title hit count the same as a body hit?).
- Presentation: getting results into the user's hands. Ranking, keyword highlighting, empty states, suggestions — this layer decides whether your search feels "smart" or "dumb."
Keep this three-layer model in mind and every architecture debate gets clearer: the difference between Postgres full-text search and Typesense is essentially a tradeoff about where the indexing and query layers live; Algolia is expensive because it does all three layers for you.
Picking a Stack: Four Options, Start With This Table
Stack selection for a solo project follows completely different logic than at a big company. Big companies optimize for QPS and SLAs; you optimize for three things: cost, operational burden, and Chinese-language support. Here are the four mainstream options laid out against those axes:
| Option | Cost | Ops burden | Chinese tokenization | Best for |
|---|---|---|---|---|
| Postgres full-text search (tsvector) | 0 (your database already does this) | 0 (zero new components) | Not natively supported; needs the pg_jieba extension or application-layer tokenization | Projects already on Postgres, under ~10k documents, Chinese-heavy but able to accept a tokenization compromise |
| Typesense | 0 (self-hosted, open source) | Low (single Docker container, small footprint) | Decent built-in support; CJK tokenization works out of the box (check the official docs) | Projects that want an out-of-the-box search experience (typo tolerance, highlighting, suggestions) without paying Algolia money |
| Meilisearch | 0 (self-hosted, open source) | Low (single binary, dead-simple deploy) | Supports Chinese, though less refined (check the official docs) | Same niche as Typesense; the API feels more intuitively RESTful — mostly a matter of taste |
| Algolia | Usage-based after the free tier; typically tens of dollars a month for a solo project | 0 (fully managed) | Good, officially maintained Chinese support | Projects where search is the core selling point, willing to pay for experience, and unwilling to touch ops |
My take is blunt: 90% of vibe-coded side projects should pick between Postgres full-text search and Typesense. Algolia's experience genuinely is the best, but its pricing is metered on search operations and record counts, and a solo project's traffic curve is spiky — one post going viral on social media can hand you a surprise bill. Unless search is your moat, don't bolt a metered dependency onto yourself early. Meilisearch vs. Typesense is mostly taste; I use Typesense in this guide because its typo tolerance and instantsearch ecosystem are friendlier to beginners.
The one-sentence decision rule for Postgres vs. Typesense: if your content already lives in Postgres and your search requirement is "findable," pick Postgres tsvector — half a day. If you want search that "feels smart" (finds things despite typos, suggests as you type), add a Typesense container — one weekend.
A bit more on Algolia, because it's the easiest trap for newcomers: the free tier looks generous, but billing is dual-track — search requests plus indexed records. Solo-project traffic is most unpredictable exactly when a post gets shared: hundreds of searches a day normally, a month's quota burned in an hour after going viral. The sneakier one is record count: stuff multiple fields and multiple language versions of every item into the index and your record count inflates faster than you'd expect. A metered service plus unpredictable traffic means handing pricing power to luck. Consider it once monthly revenue comfortably covers the bill; until then, the fixed cost of self-hosting is far kinder to indie developers.
One more tiebreaker between Typesense and Meilisearch: look at their dashboards. Typesense ships a usable admin UI out of the box where you can inspect the index and tune search parameters visually; Meilisearch's equivalent is thinner. For a one-person team, "verify search quality without writing code" is worth real money — tuning relevance is a loop of test-search, look at results, tweak parameters, and a visual dashboard compresses that loop from hours to minutes.
Hands-On 1: Postgres tsvector, Search With Zero New Components
Many people have heard of Postgres full-text search without ever really using it, assuming it's a toy. That impression is outdated. For datasets under ~10k documents, a tsvector query backed by a GIN index runs in milliseconds — the bottleneck is never Postgres, it's the index you didn't build.
Step 1: Migration — add the tsvector column and GIN index
Say you have a posts table with title and body. Don't compute to_tsvector(title || ' ' || body) at query time — that prevents the index from being used. The correct approach is a materialized column plus a GIN index:
-- migration: add full-text search to posts
ALTER TABLE posts ADD COLUMN search_vector tsvector;
-- backfill existing rows: weight A for titles, B for bodies
UPDATE posts SET search_vector =
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B');
-- GIN index — this is the entire secret of full-text search performance
CREATE INDEX posts_search_vector_idx ON posts USING GIN (search_vector);
-- trigger to keep search_vector maintained on insert/update
CREATE OR REPLACE FUNCTION posts_search_vector_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW.body, '')), 'B');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
CREATE TRIGGER posts_search_vector_update
BEFORE INSERT OR UPDATE OF title, body ON posts
FOR EACH ROW EXECUTE FUNCTION posts_search_vector_trigger();
Note the trigger says BEFORE INSERT OR UPDATE OF title, body — scoping it to those columns means updates to other fields won't trigger pointless vector recomputation. A small detail that saves real write amplification when like counts or view counters update frequently.
Step 2: Chinese tokenization
This is Postgres full-text search's one hard weakness: to_tsvector's built-in tokenizers effectively don't segment Chinese — "我爱吃拉面" becomes a single token, so searching "拉面" won't match "我爱吃拉面." Three paths forward:
- pg_jieba extension: best segmentation quality; query with
to_tsvector('jieba', ...). The price is installing an extension into your database — managed Postgres providers (Supabase, Neon) may not support it, so check your provider's docs first. - Application-layer tokenization: segment the text in your app with a Chinese tokenizer at write time, join tokens with spaces, and feed the result to
to_tsvector('simple', ...)(the simple configuration does no further tokenization and accepts input as-is). The most robust compromise: no database extension required, works on any Postgres including Vercel + Neon combos. - The pragmatic mixed-language approach: if your content mixes Chinese and English (extremely common in vibe-coded projects), keep English words intact during application-layer tokenization, segment the Chinese, lowercase everything, and feed it to the simple configuration. In practice this is completely adequate for blogs, docs, and product-title search.
My recommendation: ship with application-layer tokenization first. pg_jieba is the more elegant solution, but it ties you to a specific Postgres distribution; application-layer tokenization is twenty extra lines of code in exchange for running on any Postgres. Consider migrating to pg_jieba when you're genuinely doing tens of thousands of searches a day.
Step 3: The query — weighted ranking is the soul
-- $1 is the user input, tokenized/cleaned in the application layer first
SELECT id, title,
ts_rank(search_vector, query) AS rank
FROM posts, plainto_tsquery('simple', $1) AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;
Key points:
plainto_tsquerytreats user input as plain text and ANDs the terms automatically — a user typing "ramen delicious" must match both. Far safer thanto_tsquery, which would interpret&and|in user input as operators — an injection-style bug waiting to happen.ts_rankcombined with the A/B weights fromsetweightnaturally ranks title hits above body hits. Ranking is half the search experience; unweighted full-text search just dumps users into a pile of results to dig through themselves.- To go further, replace
ORDER BY rank DESCwith a weighted blend ofrankand recency/popularity — giving fresh content a slight boost at equal relevance is the unwritten rule of every content product. - Postgres 11+ also offers
websearch_to_tsquery, which understands the Google-style syntax users already know: quotes for exact phrases, minus for exclusion. A user typing"ramen" -tokyojust works. It's the upgraded sibling ofplainto_tsquery— if your users already think in search-engine syntax, use this directly instead of parsing queries yourself.
Hands-On 2: Typesense, "Smart Search" in One Docker Container
The Postgres approach solves "findable"; Typesense solves "delightful." Its killer trio — typo tolerance, instant search, and faceting — all work out of the box. For a solo project the biggest draw is operational cost: a single Docker container with a small memory footprint, no JVM tuning, no cluster to babysit.
One-line launch, then create the collection
docker run -d --name typesense \
-p 8108:8108 \
-v /data/typesense:/data \
-e TYPESENSE_API_KEY=your-admin-api-key \
-e TYPESENSE_DATA_DIR=/data \
typesense/typesense:27.1
Then define the collection schema (check the official docs — field names may evolve across versions):
curl -X POST 'http://localhost:8108/collections' \
-H "X-TYPESENSE-API-KEY: your-admin-api-key" \
-H 'Content-Type: application/json' \
-d '{
"name": "posts",
"fields": [
{"name": "title", "type": "string"},
{"name": "body", "type": "string"},
{"name": "created_at", "type": "int64"}
],
"default_sorting_field": "created_at"
}'
Syncing data: think triggers, not hand-rolled scripts
Typesense is not your primary database — it's a search index. Source of truth stays in Postgres; Typesense holds only the fields used for searching and display. Pick a sync strategy by data size:
- Small projects (under ~10k docs): call Typesense's upsert/delete right inside the API handlers that create, update, or delete content. One line of code each — don't over-engineer it.
- A bit bigger: use Postgres LISTEN/NOTIFY or a periodic sync script for eventual consistency. A few seconds of index lag is imperceptible to users.
A classic beginner mistake is treating Typesense as the primary store and stuffing full objects into it. It's a search engine, not a database — leaner fields mean a smaller index and faster search.
Frontend: instantsearch wiring, visible results in half an hour
Typesense maintains an official instantsearch adapter, and the frontend experience is "results change with every keystroke" — that instant feedback is the single biggest reason users perceive a search as "smart":
import TypesenseInstantSearchAdapter from
'typesense-instantsearch-adapter';
import { InstantSearch, SearchBox, Hits } from
'react-instantsearch';
const adapter = new TypesenseInstantSearchAdapter({
server: {
apiKey: 'your-search-only-api-key', // read-only key — never the admin key
nodes: [{ host: 'search.your-domain.com', port: 443, protocol: 'https' }],
},
additionalSearchParameters: { query_by: 'title,body' },
});
const searchClient = adapter.searchClient;
export function Search() {
return (
<InstantSearch searchClient={searchClient} indexName="posts">
<SearchBox placeholder="Search titles or body…" />
<Hits />
</InstantSearch>
);
}
Security red line: the frontend may only use a search-only API key. Typesense supports per-key scoping (search specific collections only, no writes) — leaking the admin key hands index write access to strangers. A scoped key in frontend code is the intended usage, but it must be the least-privileged one.
The Experience Details: Six Things That Decide "Smart" vs. "Dumb"
Picking the right engine only gets you a passing grade. What users actually feel are the six details below. Each is a small piece of work, but together they're the difference between "this search is great" and "what is this thing."
1. Input debouncing: don't fire a query per keystroke
Typing "ramen" is several keystrokes — without debouncing that's several queries. It wastes resources, and worse, results flicker with every keypress, which feels cheap. The standard practice is 200–300ms of debounce:
import { useState, useEffect } from 'react';
// Generic debounce hook: value only propagates after delay ms of stability
export function useDebounce<T>(value: T, delay = 250): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // new input cancels the pending one
}, [value, delay]);
return debounced;
}
// Usage: bind the input to rawQuery, fire requests with debouncedQuery
const [rawQuery, setRawQuery] = useState('');
const debouncedQuery = useDebounce(rawQuery, 250);
useEffect(() => {
if (debouncedQuery.trim()) fetchResults(debouncedQuery);
}, [debouncedQuery]);
2. Keyword highlighting: let users see why this result matched
Highlighting matched terms in results is the cheapest, highest-impact UX optimization there is. It answers the question in the user's head: "what does this result have to do with my query?" Typesense/Meilisearch return highlighted snippets directly; with Postgres you can use ts_headline:
SELECT id, title,
ts_headline('simple', body, plainto_tsquery('simple', $1),
'StartSel=<mark>, StopSel=</mark>, MaxFragments=2') AS snippet
FROM posts, plainto_tsquery('simple', $1) AS query
WHERE search_vector @@ query
ORDER BY ts_rank(search_vector, query) DESC
LIMIT 20;
Note: inlining HTML tags in the ts_headline options is example shorthand — in production, escape properly. The query comes from user input, and snippets must pass through XSS filtering before hitting innerHTML.
3. Empty states: when nothing matches, don't just say "no results"
"No results found" is where search experiences go to die. A good empty state does three things: acknowledge, suggest, and offer an exit. "Nothing for 'ramen' — try 'noodles'?" plus a few trending searches plus a button back home. Half an hour of work, roughly half the churn.
An advanced move: the zero-result page is one of the highest-intent ad slots on your entire site. The user's intent on this page is razor-sharp — they want something you don't have. Instead of a dry apology, offer "follow this keyword and we'll notify you when new content arrives." For content products, the subscription relationships accumulated through that one small feature are worth more long-term than search itself. Plenty of newsletter products cold-started exactly this way: let users search first, offer a subscription when search fails, notify them back once content accumulates.
4. Spell correction: "ramne" should still find "ramen"
IME autocomplete misfires and pinyin typos are the most common failure mode in Chinese search. Typesense's typo tolerance works out of the box; the Postgres stack can fall back on the pg_trgm extension for fuzzy matching:
-- pg_trgm: trigram similarity, the budget spell-correction option
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX posts_title_trgm_idx ON posts USING GIN (title gin_trgm_ops);
-- similarity above 0.3 counts as a hit (tune the threshold to your data)
SELECT id, title, similarity(title, $1) AS sml
FROM posts
WHERE title % $1
ORDER BY sml DESC
LIMIT 10;
The production strategy: run full-text search first and return results if there are any; only on zero results, run the trigram fuzzy query and show "did you mean: XXX" in the UI. A two-stage funnel that protects precision while rescuing recall.
5. Search query analytics: users are telling you what they want
This is the most underrated part of the whole stack. Log search terms (deduplicated, with counts and zero-result rates) and review them weekly — you'll get a free user-requirements document: terms searched often with zero results are content you should produce; popular terms deserve a spot on the homepage. Implementation is trivial — log a line in your search API:
// Next.js search API route: log the query (pseudocode)
export async function GET(req: Request) {
const q = new URL(req.url).searchParams.get('q')?.trim() ?? '';
if (q.length >= 2) {
// fire-and-forget — never block the search response
logSearchQuery(q).catch(() => {});
}
const results = await searchPosts(q);
return Response.json({ results });
}
async function logSearchQuery(q: string) {
// upsert: increment count if the term exists, insert otherwise
await db.$executeRaw`
INSERT INTO search_logs (query, count, zero_result_count, updated_at)
VALUES (${q}, 1, 0, NOW())
ON CONFLICT (query) DO UPDATE
SET count = search_logs.count + 1, updated_at = NOW()`;
}
Privacy note: log the term itself, never who searched it. A solo project has no legal department, but lost user trust is more fatal than any legal problem.
6. Mobile: don't bury the search box in the hamburger menu
Over half your traffic is mobile, yet many vibe-coded projects hide search two levels deep on small screens. The rule is simple: visible on first screen, one tap to search. If the homepage's first screen can't fit a search box, the homepage's information architecture has a problem of its own.
The Hidden Costs of Search: Indexes Aren't a Free Lunch
When choosing a stack, everyone prices query speed and nobody prices the index. Indexing has three hidden bills:
Bill one: write amplification. In the Postgres approach, every title/body update recomputes the tsvector and updates the GIN index; in the Typesense approach, every write adds a network call. Projects with frequently-updated content (like counts, view counts in the same table) must be careful not to let hot fields trigger index rebuilds — that's why the earlier migration scoped the trigger to OF title, body. Keep counters in a separate table, or just don't sync them into the search index.
Bill two: the sync-lag trap. With an external search engine there's always a window where the primary database and the index disagree. A user publishes a post, searches immediately, finds nothing, and files it as a bug. The fix is simple: optimistically insert the new content into the local result list on the frontend instead of waiting for the index to catch up. That one trick masks 99% of perceived sync lag.
Bill three: multiple languages. Decide your mixed Chinese-English tokenization strategy on day one — switching tokenizers mid-flight means a full index rebuild. My field experience: index title and body in separate per-language fields (title_en, title_zh, etc.), query both, and merge with weights. The schema is more verbose, but future-you will be grateful when tuning — Chinese and English relevance were never meant to share one set of parameters.
Launch Checklist
Run through this list before shipping search. Every item is a real bug from a real project:
- Index coverage: is every searchable content type in the index? (Posts, bodies, titles, tags — missing tags is the most common omission.)
- Incremental updates: does the index update in real time on create/edit/delete? Deleted content still showing up in search is the most embarrassing bug there is.
- Chinese verification: test with 10 real Chinese queries and confirm tokenization is correct — especially 2–3 character short terms and proper nouns.
- Empty query: what happens when the box is submitted empty? It should either not query or return recommendations — never a full-table scan that takes the database down.
- Special characters: do
&,|,!, and quotes break anything?plainto_tsquerydefends against most of it, but the frontend should also truncate length (say, 50 characters). - Performance baseline: is it still fast at 10x data volume? For Postgres, confirm the GIN index is hit via
EXPLAIN; for Typesense, confirm memory headroom. - Permissions: can drafts or unpublished content leak through search? The search API must reuse the same visibility filters — search must never become a backdoor for unauthorized access.
- Key isolation: if you're on Typesense/Meilisearch, is the frontend key read-only and least-privilege? The admin key lives server-side only.
- Zero-result fallback: is the fuzzy query and "did you mean" prompt wired up? Does the empty state recommend content?
- Logging: has query logging started? The first week's data will tell you what users are actually looking for.
Three numbers to watch after launch
The checklist is done and search is live, but the work isn't over. Search needs continuous tuning, and three numbers a week are enough: zero-result rate (share of queries with no results — above 15% means index coverage or tokenization has a problem), top-result click-through (share of users clicking the first result — low means ranking is off), and post-search conversion (whether users keep using the product after searching — the ultimate KPI of search). The three numbers mirror the indexing, ranking, and business-value layers of the mental model from the opening. Don't panic at ugly numbers — the solo developer's advantage is shipping fast: tweak a weight, add a synonym, verify live the same day.
One last thought: search is the kind of feature that feels optional until you build it, and overdue the moment you do. It's unsexy, it has zero demo appeal, but it's the dividing line between a content product that's a toy and one that's a tool. Your content deserves to be found — start by making the search box worthy of it.
If you can only do one thing this week: add the tsvector column and GIN index to Postgres — half a day, and users can find things. Next week, consider Typesense's typo tolerance and instant suggestions. Search experience is iterated into existence, never designed in one shot — but step one has to be a search box that actually exists.
Related articles

AI helped you ship a tool in four days, but a month later you can't answer: how many people visited, where they came from, where they dropped off. This hands-on guide ships a complete analytics setup in three days — tool selection, 5 core events, Next.js tracking code, privacy compliance, and three weekly reports that turn data into decisions.

Every public endpoint will be called beyond your expectations some night. This guide builds a one-person-team rate-limiting system: algorithm choice (sliding window vs token bucket), four-layer defense, AI-endpoint money-burning protection, quota design, 429 response conventions, false-positive triage, and a launch checklist.

Every vibe project has the same darkly comic moment: your site goes white-screen and a friend tells you before your monitoring does. This guide builds a one-person-team error monitoring system: a 5-minute Sentry loop, error boundaries, report context design, backend structured logging, AI-call-specific protection, alert tiers, and a launch checklist.