Build a Great Nest.js Blog: Full-Text Search for Posts
Emily Parker
Product Engineer · Leapcell

In the previous tutorial, we added an image upload feature to our blog posts.
As time goes on, you can imagine that your blog has accumulated quite a few articles. A new problem gradually emerges: How can readers quickly find the articles they want to read?
The answer, of course, is search.
In this tutorial, we will add a full-text search feature to our blog.
You might be thinking, can't I just use a SQL LIKE '%keyword%'
query to implement search? For simple scenarios, you certainly can. However, LIKE
queries perform poorly when dealing with large blocks of text and cannot understand linguistic complexities (for example, searching for "create" won't match "creating").
Therefore, we will adopt a more professional and efficient solution: leveraging PostgreSQL's built-in Full-Text Search (FTS) feature. It's not only lightning-fast but also handles stemming, ignores stop words, and sorts by relevance, providing a search experience far superior to LIKE
.
Step 1: Database Search Infrastructure
To use PostgreSQL's FTS feature, we first need to make some modifications to our post
table. The core idea is to create a special column dedicated to storing optimized text data that can be searched at high speed.
1. Core Concept: tsvector
We will add a new column of type tsvector
to the post
table. You can think of it as a "search dictionary." It breaks down the article's title and content into individual words (lexemes) and standardizes them (for example, processing both "running" and "ran" into "run") for subsequent queries.
2. Modifying the Table Structure
Connect to your PostgreSQL database and execute the following SQL statement to add the search_vector
column to the post
table.
ALTER TABLE "post" ADD COLUMN "search_vector" tsvector;
3. Automatic Updates with a Trigger
Of course, we don't want to manually update this search_vector
column every time we create or update a post. The best way is to let the database do this work automatically. We'll achieve this by creating a trigger.
First, create a function whose purpose is to concatenate the title
and content
and convert them into the tsvector
format.
CREATE OR REPLACE FUNCTION update_post_search_vector() RETURNS TRIGGER AS $$ BEGIN NEW.search_vector := setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') || setweight(to_tsvector('english', coalesce(NEW.content, '')), 'B'); RETURN NEW; END; $$ LANGUAGE plpgsql;
Tip: The
setweight
function allows you to assign different weights to text from different fields. Here, we've given the title ('A') a higher weight than the content ('B'), which means that in search results, articles with the keyword in the title will be ranked higher.
Next, create a trigger that automatically calls the function we just created every time a new post is inserted (INSERT
) or updated (UPDATE
).
CREATE TRIGGER post_search_vector_update BEFORE INSERT OR UPDATE ON "post" FOR EACH ROW EXECUTE FUNCTION update_post_search_vector();
4. Creating a Search Index
To make our search lightning-fast, the final step is to create a GIN (Generalized Inverted Index) on our new search_vector
column.
CREATE INDEX post_search_vector_idx ON "post" USING gin(search_vector);
Now, your database is ready! It will automatically maintain an efficient search index for every article.
Step 2: Building the Search Logic in Nest.js
With the database layer prepared, let's return to our Nest.js project to write the backend code for handling search requests.
1. Update PostsService
Open src/posts/posts.service.ts
, where we need to add a new search
method.
// src/posts/posts.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Post } from './post.entity'; @Injectable() export class PostsService { constructor( @InjectRepository(Post) private postsRepository: Repository<Post> ) {} // ... findAll, findOne, create methods remain unchanged async search(query: string): Promise<Post[]> { if (!query) { return []; } // Use QueryBuilder to construct a more complex query return this.postsRepository .createQueryBuilder('post') .select() .addSelect("ts_rank(post.search_vector, to_tsquery('english', :query))", 'rank') .where("post.search_vector @@ to_tsquery('english', :query)", { query: `${query.split(' ').join(' & ')}` }) .orderBy('rank', 'DESC') .getMany(); } }
Code Explanation:
- We use TypeORM's
QueryBuilder
because it gives us more flexibility to write complex SQL queries. to_tsquery('english', :query)
: This function converts the user's input search string (e.g., "nestjs blog") into a special query type that can be matched against atsvector
column. We join multiple words with&
to indicate that all words must be matched.- The
@@
operator: This is the "match" operator for full-text search. The linewhere("post.search_vector @@ ...")
is the core of the search operation. ts_rank(...)
: This function calculates a "relevance rank" based on how well the query terms match the text..orderBy('rank', 'DESC')
: We sort by this rank in descending order to ensure the most relevant articles appear at the top.
2. Create the Search Route
Next, add a new route in src/posts/posts.controller.ts
to handle search requests.
// src/posts/posts.controller.ts import { Controller, Get, Render, Param, Post, Body, Res, UseGuards, Request, Query } from '@nestjs/common'; // ... other imports @Controller() // Move 'posts' to individual methods export class PostsController { constructor(private readonly postsService: PostsService) {} @Get() // Root path @Render('index') async root(@Request() req) { // ... } @Get('posts') // Homepage redirect @Render('index') async findAll(@Request() req) { const posts = await this.postsService.findAll(); return { posts, user: req.user }; } // The new search route @Get('search') @Render('search-results') async search(@Query('q') query: string, @Request() req) { const posts = await this.postsService.search(query); return { posts, user: req.user, query }; } @UseGuards(AuthenticatedGuard) @Get('posts/new') // ... // ... other methods }
Note: To make the /search
route work, we've slightly adjusted the structure of PostsController
. We changed @Controller('posts')
to @Controller()
and explicitly specified the route path on each method, such as @Get('posts')
.
Step 3: Integrating the Search Feature into the Frontend
With the backend API ready, let's now add a search box and a search results page to the user interface.
1. Add the Search Box
Open the views/_header.ejs
file and add a search form to the navigation bar.
<header> <h1><a href="/">My Blog</a></h1> <form action="/search" method="GET" class="search-form"> <input type="search" name="q" placeholder="Search posts..." /> <button type="submit">Search</button> </form> <div class="user-actions"> <% if (user) { %> <span>Welcome, User</span> <a href="/posts/new" class="new-post-btn">New Post</a> <a href="/auth/logout">Logout</a> <% } else { %> <a href="/auth/login">Login</a> <a href="/users/register">Register</a> <% } %> </div> </header>
2. Create the Search Results Page
Create a new file named search-results.ejs
in the views
directory. This page will be used to display the search results.
<%- include('_header', { title: 'Search Results' }) %> <div class="search-results-container"> <h2>Search Results for: "<%= query %>"</h2> <% if (posts.length > 0) { %> <div class="post-list"> <% posts.forEach(post => { %> <article class="post-item"> <h2><a href="/posts/<%= post.id %>"><%= post.title %></a></h2> <p><%= post.content.substring(0, 150) %>...</p> <small><%= new Date(post.createdAt).toLocaleDateString() %></small> </article> <% }) %> </div> <% } else { %> <p>No posts found matching your search. Please try different keywords.</p> <% } %> </div> <%- include('_footer') %>
This template is simple: it first displays the user's search query, then checks the posts
array. If the array is not empty, it iterates through and displays the list of articles (just like the homepage); if it's empty, it shows a "No posts found" message.
Run and Test
All done! Now, restart your application:
npm run start:dev
Open your browser, and you should see a new search box at the top of the page.
- Enter a word that exists in one of your post's titles or content and press Enter.
- The page should navigate to the search results page and display the relevant articles.
- Try searching for a word that doesn't exist, and the page will kindly inform you, "No posts found."
- If one of your articles contains the word "creating," try searching for "create" to see if PostgreSQL's powerful stemming feature correctly matches the result!
Your blog now supports a full-text search feature. No matter how much you write, your readers will no longer get lost.