You have 500 users. Your app has a growing list of records -- projects, contacts, notes, products -- and people are starting to email you: "I can't find the thing I created last week."
The reflex is to reach for Algolia, Typesense, or Meilisearch. You add another service, another API key, another monthly bill ($35 to $100 before you have meaningful search volume), and another sync job to keep the index up to date.
Here is what most tutorials do not tell you: PostgreSQL -- the database you are already running on Neon -- has full-text search built in. It handles typos, ranks results by relevance, and runs in milliseconds on realistic SaaS data sizes. You do not need a separate service until you are at a scale most SaaS products never reach.
PostgreSQL stores a processed, indexed version of your text in a tsvector column. When a user searches, you convert the query to a tsquery and PostgreSQL matches against the index.
The two key functions:
to_tsvector(lang, text) -- converts a string to a searchable vector, removing stopwords and stemming words ("running" matches "run")plainto_tsquery(lang, query) -- converts raw user input to a query that matches the vector safelyA generated column keeps the index current automatically -- no sync job needed.
Add a search_vector generated column to the table you want to search. Here is the SQL migration for a posts table:
ALTER TABLE posts
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector(english, coalesce(title, ) || || coalesce(content, ))
) STORED;
CREATE INDEX posts_search_idx ON posts USING GIN (search_vector);
Generate and apply this with the standard Drizzle commands:
npx drizzle-kit generate
npx drizzle-kit migrate
The GIN index builds in the background on Neon and does not block reads.
With the index in place, the query is a single .where() clause:
// modules/post/post.repo.ts
async function searchPosts(query: string, authorId: string) {
return db
.select()
.from(postTable)
.where(
and(
eq(postTable.authorId, authorId),
sql`search_vector @@ plainto_tsquery(english, ${query})`
)
)
.orderBy(
sql`ts_rank(search_vector, plainto_tsquery(english, ${query})) DESC`
)
.limit(20);
}
plainto_tsquery is forgiving with raw user input -- it will not throw on special characters the way to_tsquery does. ts_rank orders results by relevance so exact matches come first.
The route follows the same thin pattern as every other endpoint in the boilerplate -- validate, call the service, respond:
// app/api/posts/search/route.ts
export async function GET(req: Request) {
try {
const user = await getUserFromRequest(req);
const { searchParams } = new URL(req.url);
const result = SearchQuerySchema.safeParse({ q: searchParams.get(q) });
if (!result.success) throw new HttpError(400, Invalid query);
const posts = await postService.search(result.data.q, user.id);
return Response.json(posts);
} catch (error: unknown) {
return handleError(error);
}
}
On the client, a debounced input calls GET /api/posts/search?q=... via SWR and updates the list in real time. No extra state management needed.
This is the decision most tutorials skip. Here is a straightforward framework:
Use PostgreSQL search when:
Consider a dedicated search service when:
For most SaaS products, that second case does not arrive until well after you have product-market fit and revenue to justify the extra service. Start with PostgreSQL, keep the search logic inside your repository layer, and migrate the implementation later without changing the API.
The Next.js SaaS Boilerplate already includes the Drizzle ORM setup, the auth layer that scopes queries to the signed-in user, and the API route pattern shown above. Adding search is a migration, a repository method, and one new route -- a few hours of focused work, not a sprint.
The database setup docs walk through schema conventions and how to run migrations on Neon.
If your users have asked for search -- or you know they will -- add it this week. The schema change is non-destructive, the index builds without downtime on Neon, and the query pattern above handles real SaaS workloads without a new monthly bill.
Get the boilerplate and ship search before your users ask for it: boilerplate.iteam-company.com.