# Bài 21 — Tìm kiếm & phân trang
⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️
🎯 Mục tiêu
Ô tìm kiếm trên navbar của source gốc không hoạt động — nó chỉ là một
<input> không có onChange, không có onSubmit. Bài này làm cho nó chạy thật:
- API tìm kiếm với regex và text index
- API phân trang với
skip/limit - Debounce ô tìm kiếm để không gọi API mỗi ký tự
- Trang
/searchvới kết quả và phân trang - Đồng bộ trạng thái với URL để chia sẻ link được
📚 1. Vì sao cần chuyển lọc sang server?
Bài 15 lọc và sắp xếp ở client — tải hết 12 sản phẩm rồi lọc bằng JavaScript.
Với dữ liệu thật:
| Số sản phẩm | Client-side | Server-side |
|---|---|---|
| 12 | 30 KB, tức thì | Thừa thãi |
| 1.000 | 2 MB, hơi chậm | 25 KB/trang |
| 100.000 | 200 MB, trình duyệt treo | 25 KB/trang |
Ngoài hiệu năng, server-side còn cho phép:
- Chia sẻ link kết quả (
?q=gucci&page=2) - Google index được từng trang
- F5 không mất trạng thái
💻 2. Backend: API tìm kiếm + phân trang
Thay thế route GET /products trong backend/routes/products.js:
// ============================================
// GET /products
// Query params:
// q — từ khóa tìm kiếm
// category — lọc theo danh mục (ObjectId)
// minPrice — giá tối thiểu
// maxPrice — giá tối đa
// sort — price_asc | price_desc | rating | newest
// page — trang hiện tại (mặc định 1)
// limit — số sản phẩm/trang (mặc định 12, tối đa 100)
// ============================================
router.get("/", async (req, res, next) => {
try {
const db = await connectDb();
const products = db.collection("products");
// ===== 1. Xây bộ lọc =====
const filter = {};
if (req.query.q && req.query.q.trim()) {
// escape ký tự đặc biệt của regex để tránh ReDoS và lỗi cú pháp
const keyword = req.query.q.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
filter.$or = [
{ name: { $regex: keyword, $options: "i" } },
{ description: { $regex: keyword, $options: "i" } },
];
}
if (req.query.category && ObjectId.isValid(req.query.category)) {
filter.categoryId = new ObjectId(req.query.category);
}
const minPrice = Number(req.query.minPrice);
const maxPrice = Number(req.query.maxPrice);
if (!isNaN(minPrice) || !isNaN(maxPrice)) {
filter.price = {};
if (!isNaN(minPrice)) filter.price.$gte = minPrice;
if (!isNaN(maxPrice)) filter.price.$lte = maxPrice;
}
// ===== 2. Sắp xếp =====
const sortMap = {
price_asc: { price: 1 },
price_desc: { price: -1 },
rating: { rating: -1 },
newest: { _id: -1 },
};
const sort = sortMap[req.query.sort] || { _id: 1 };
// ===== 3. Phân trang =====
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 12));
const skip = (page - 1) * limit;
// ===== 4. Truy vấn song song: dữ liệu + tổng số =====
const [items, total, categories] = await Promise.all([
products.find(filter).sort(sort).skip(skip).limit(limit).toArray(),
products.countDocuments(filter),
db.collection("categories").find().toArray(),
]);
// ===== 5. Ghép danh mục =====
const data = items.map((item) => ({
...item,
category: categories.find((c) => c._id.equals(item.categoryId)) || null,
}));
res.status(200).json({
data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
},
});
} catch (error) {
next(error);
}
});
⚠️ Thay đổi phá vỡ tương thích (breaking change)
API cũ trả về mảng:
[ { "_id": "...", "name": "..." } ]
API mới trả về object có data và pagination:
{
"data": [ { "_id": "...", "name": "..." } ],
"pagination": { "page": 1, "total": 12, "totalPages": 1 }
}
→ Mọi component đang dùng data.map(...) sẽ crash với data.map is not a function.
Hai cách xử lý:
a) Sửa frontend — đổi data thành data.data:
const { data } = useSWR(`${API}/products`, fetcher);
<Productlist data={data?.data ?? []} />
b) Giữ tương thích ngược — chỉ trả object khi có query param:
const hasQuery = Object.keys(req.query).length > 0;
if (!hasQuery) {
return res.status(200).json(data); // mảng như cũ
}
res.status(200).json({ data, pagination });
Tài liệu chọn cách (a) — rõ ràng hơn về lâu dài. Nhớ sửa cả page.js,
menu/page.js, admin/product/page.js, admin/page.js.
🔍 Escape regex — chi tiết bảo mật quan trọng
const keyword = req.query.q.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
Nếu không escape, người dùng gõ (a+)+$ sẽ tạo ra một regex có độ phức tạp
hàm mũ (ReDoS — Regular expression Denial of Service). MongoDB sẽ treo CPU
và server ngừng phục vụ.
replace biến mọi ký tự đặc biệt thành ký tự thường: . → \., * → \*...
⚠️ Route
/search/:keywordtrongroutes/index.jsgốc không escape:const products = await productCollection .find({ name: new RegExp(req.params.keyword, 'i') }).toArray();May mà file này không được mount nên chưa gây hại (bài 04).
📚 3. Text index — tìm kiếm nhanh hơn regex
$regex phải quét toàn bộ collection. Với dữ liệu lớn, dùng text index:
// Chạy 1 lần trong mongosh
use lith_perfume
db.products.createIndex(
{ name: "text", description: "text" },
{ default_language: "none" } // "none" để hỗ trợ tiếng Việt tốt hơn
)
Rồi truy vấn:
filter.$text = { $search: keyword };
$regex |
$text |
|
|---|---|---|
| Tốc độ | Chậm (quét toàn bộ) | Nhanh (dùng index) |
| Tìm giữa từ | ✅ "uci" khớp "Gucci" |
❌ chỉ khớp từ hoàn chỉnh |
| Nhiều từ khóa | Phải tự ghép | ✅ tự động |
| Xếp hạng liên quan | ❌ | ✅ { score: { $meta: "textScore" } } |
| Tiếng Việt có dấu | ✅ tốt | ⚠️ cần cấu hình |
Với dự án nhỏ, $regex đủ dùng và linh hoạt hơn. Tài liệu dùng $regex.
Thêm index cho các trường hay lọc
db.products.createIndex({ categoryId: 1 })
db.products.createIndex({ price: 1 })
db.products.createIndex({ rating: -1 })
💻 4. Frontend: ô tìm kiếm có debounce
Vấn đề
Gõ "gucci" = 5 ký tự = 5 request API. Với 100 người dùng đang gõ, server nhận
hàng trăm request/giây trong khi chỉ request cuối là có ý nghĩa.
Debounce = chờ người dùng ngừng gõ 400ms rồi mới gọi API.
Hook useDebounce
Tạo frontend/lib/useDebounce.js:
"use client"
import { useEffect, useState } from "react";
export function useDebounce(value, delay = 400) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // hủy timer cũ khi value đổi
}, [value, delay]);
return debounced;
}
🔍 Cách nó hoạt động
Gõ "g" → timer A đặt (400ms)
Gõ "gu" → cleanup hủy timer A, timer B đặt
Gõ "guc" → cleanup hủy timer B, timer C đặt
Gõ "gucc" → cleanup hủy timer C, timer D đặt
Gõ "gucci" → cleanup hủy timer D, timer E đặt
(ngừng gõ 400ms) → timer E chạy → setDebounced("gucci")
Chỉ 1 lần cập nhật thay vì 5. Chìa khóa là hàm dọn dẹp return () => clearTimeout(timer) —
React gọi nó trước mỗi lần effect chạy lại.
Cập nhật Navbar
"use client"
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
export default function Navbar() {
const pathname = usePathname();
const router = useRouter();
const cart = useSelector((state) => state.cart);
const [keyword, setKeyword] = useState("");
const totalitem = useMemo(
() => cart.reduce((total, item) => total + item.quantity, 0),
[cart]
);
const handleSearch = (e) => {
e.preventDefault();
if (!keyword.trim()) return;
router.push(`/search?q=${encodeURIComponent(keyword.trim())}`);
};
return (
<nav className="navbar navbar-expand-lg bg-dark" data-bs-theme="dark">
<div className="container-fluid">
{/* ... brand, toggler, menu trái như bài 11 ... */}
<form className="d-flex" role="search" onSubmit={handleSearch}>
<input
className="form-control"
type="search"
placeholder="Tìm kiếm sản phẩm..."
aria-label="Search"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<button className="btn btn-outline-light ms-2" type="submit">
<i className="fa-solid fa-magnifying-glass"></i>
</button>
</form>
{/* ... menu phải như bài 11 ... */}
</div>
</nav>
);
}
encodeURIComponent mã hóa ký tự đặc biệt để URL hợp lệ:
"nước hoa" → "n%C6%B0%E1%BB%9Bc%20hoa".
💻 5. Trang kết quả tìm kiếm
Tạo frontend/app/(user)/search/page.js:
"use client"
import { useSearchParams, useRouter } from "next/navigation";
import { Suspense, useState, useEffect } from "react";
import useSWR from "swr";
import Product from "../component/product";
import Pagination from "../component/pagination";
import { useDebounce } from "@/lib/useDebounce";
import { fetcher, API } from "@/lib/fetcher";
function SearchContent() {
const searchParams = useSearchParams();
const router = useRouter();
const q = searchParams.get("q") ?? "";
const page = Number(searchParams.get("page")) || 1;
const sort = searchParams.get("sort") ?? "";
const [keyword, setKeyword] = useState(q);
const debouncedKeyword = useDebounce(keyword, 400);
// Khi từ khóa đã debounce thay đổi → cập nhật URL
useEffect(() => {
if (debouncedKeyword === q) return;
const params = new URLSearchParams();
if (debouncedKeyword) params.set("q", debouncedKeyword);
if (sort) params.set("sort", sort);
router.replace(`/search?${params.toString()}`);
}, [debouncedKeyword, q, sort, router]);
const url = `${API}/products?q=${encodeURIComponent(q)}&page=${page}&limit=12${
sort ? `&sort=${sort}` : ""
}`;
const { data, error, isLoading } = useSWR(url, fetcher, {
keepPreviousData: true, // giữ kết quả cũ khi đang tải trang mới
});
const changeParam = (key, value) => {
const params = new URLSearchParams(searchParams.toString());
if (value) params.set(key, value);
else params.delete(key);
if (key !== "page") params.delete("page"); // đổi bộ lọc thì về trang 1
router.push(`/search?${params.toString()}`);
};
return (
<div className="container mt-5">
<h2>Tìm kiếm sản phẩm</h2>
<div className="row g-2 my-4">
<div className="col-md-8">
<input
className="form-control form-control-lg"
placeholder="Nhập tên sản phẩm..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
</div>
<div className="col-md-4">
<select
className="form-select form-select-lg"
value={sort}
onChange={(e) => changeParam("sort", e.target.value)}
>
<option value="">Mặc định</option>
<option value="price_asc">Giá tăng dần</option>
<option value="price_desc">Giá giảm dần</option>
<option value="rating">Đánh giá cao nhất</option>
<option value="newest">Mới nhất</option>
</select>
</div>
</div>
{error && <div className="alert alert-danger">Lỗi khi tải dữ liệu</div>}
{isLoading && !data && <p>Đang tìm kiếm...</p>}
{data && (
<>
<p className="text-muted">
{data.pagination.total === 0
? `Không tìm thấy kết quả nào cho "${q}"`
: `Tìm thấy ${data.pagination.total} sản phẩm${q ? ` cho "${q}"` : ""}`}
</p>
<div className="row row-cols-lg-4 row-cols-md-3 row-cols-sm-2 row-cols-1 g-3">
{data.data.map((item) => (
<div key={item._id} className="col">
<Product data={item} />
</div>
))}
</div>
<Pagination
pagination={data.pagination}
onChange={(p) => changeParam("page", p)}
/>
</>
)}
</div>
);
}
export default function SearchPage() {
// useSearchParams cần bọc trong Suspense ở App Router
return (
<Suspense fallback={<div className="container mt-5">Đang tải...</div>}>
<SearchContent />
</Suspense>
);
}
⚠️ useSearchParams phải bọc trong <Suspense>
Nếu không, Next.js báo lỗi khi build:
useSearchParams() should be wrapped in a suspense boundary at page "/search"
Vì useSearchParams khiến trang phải render động, Next.js cần một ranh giới
Suspense để tách phần tĩnh và phần động.
keepPreviousData: true
Khi chuyển sang trang 2, SWR bình thường sẽ trả data = undefined trong lúc tải
→ màn hình trắng rồi mới hiện lại. Với keepPreviousData, kết quả trang 1 vẫn hiển thị
cho tới khi trang 2 về. Trải nghiệm mượt hơn nhiều.
💻 6. Component phân trang
Tạo frontend/app/(user)/component/pagination.js:
"use client"
export default function Pagination({ pagination, onChange }) {
const { page, totalPages, hasNext, hasPrev } = pagination;
if (totalPages <= 1) return null;
// Tính dải số trang hiển thị: tối đa 5 nút quanh trang hiện tại
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
const end = Math.min(totalPages, start + 4);
const pages = [];
for (let i = start; i <= end; i++) pages.push(i);
return (
<nav className="mt-4" aria-label="Phân trang">
<ul className="pagination justify-content-center">
<li className={`page-item ${!hasPrev ? "disabled" : ""}`}>
<button className="page-link" onClick={() => onChange(page - 1)} disabled={!hasPrev}>
« Trước
</button>
</li>
{start > 1 && (
<>
<li className="page-item">
<button className="page-link" onClick={() => onChange(1)}>1</button>
</li>
{start > 2 && <li className="page-item disabled"><span className="page-link">…</span></li>}
</>
)}
{pages.map((p) => (
<li key={p} className={`page-item ${p === page ? "active" : ""}`}>
<button className="page-link" onClick={() => onChange(p)}>{p}</button>
</li>
))}
{end < totalPages && (
<>
{end < totalPages - 1 && <li className="page-item disabled"><span className="page-link">…</span></li>}
<li className="page-item">
<button className="page-link" onClick={() => onChange(totalPages)}>{totalPages}</button>
</li>
</>
)}
<li className={`page-item ${!hasNext ? "disabled" : ""}`}>
<button className="page-link" onClick={() => onChange(page + 1)} disabled={!hasNext}>
Sau »
</button>
</li>
</ul>
<p className="text-center text-muted small">
Trang {page} / {totalPages}
</p>
</nav>
);
}
📚 7. skip/limit và vấn đề với dữ liệu lớn
products.find(filter).skip((page - 1) * limit).limit(limit)
Cách này đơn giản nhưng có nhược điểm: để lấy trang 1000 (skip 12.000), MongoDB vẫn phải duyệt qua 12.000 document rồi bỏ đi.
Giải pháp cho dữ liệu rất lớn: cursor-based pagination
// Thay vì ?page=1000, dùng ?after=<id-cuối-của-trang-trước>
const filter = { _id: { $gt: new ObjectId(req.query.after) } };
products.find(filter).sort({ _id: 1 }).limit(limit)
Nhanh hơn nhiều vì dùng index trực tiếp, nhưng không nhảy được tới trang bất kỳ — chỉ có "trang tiếp theo". Phù hợp với dạng cuộn vô hạn (infinite scroll).
Với dự án này, skip/limit là quá đủ.
✅ 8. Kiểm thử
Test API bằng trình duyệt
| URL | Kiểm tra |
|---|---|
localhost:5000/products |
{ data: [...12 sp], pagination: { total: 12, totalPages: 1 } } |
localhost:5000/products?limit=5 |
5 sản phẩm, totalPages: 3 |
localhost:5000/products?limit=5&page=3 |
2 sản phẩm cuối, hasNext: false |
localhost:5000/products?q=gucci |
Chỉ sản phẩm có "gucci" trong tên/mô tả |
localhost:5000/products?q=GUCCI |
Kết quả giống trên (không phân biệt hoa thường) |
localhost:5000/products?sort=price_desc |
Giá giảm dần |
localhost:5000/products?minPrice=4000000 |
Chỉ sản phẩm ≥ 4 triệu |
localhost:5000/products?q=(a+)+$ |
Trả về [], không treo server ✅ |
Test debounce
- Mở DevTools → tab Network, lọc
Fetch/XHR - Vào
/search, gõguccivới tốc độ bình thường - ✅ Chỉ có 1 request sau khi ngừng gõ
- ❌ Nếu thấy 5 request → debounce chưa hoạt động
Test phân trang
- Đổi
limit=5trong URL API để có nhiều trang - Bấm số trang → URL đổi thành
/search?page=2 - ✅ Bấm nút Back của trình duyệt → về trang 1
- ✅ Copy URL, mở tab mới → hiện đúng trang 2 với đúng từ khóa
Test tìm kiếm từ navbar
- Gõ "gucci" vào ô tìm kiếm trên navbar, Enter
- ✅ Chuyển sang
/search?q=guccivà hiện kết quả
🆘 Xử lý sự cố
<details> <summary>data.map is not a function</summary>API giờ trả về object thay vì mảng. Sửa mọi chỗ dùng:
<Productlist data={data} /> // ❌ cũ
<Productlist data={data?.data ?? []} /> // ✅ mới
Các file cần sửa: app/(user)/page.js, menu/page.js, menu/[id]/page.js,
(admin)/admin/product/page.js, (admin)/admin/page.js.
Bọc component dùng useSearchParams trong <Suspense> như mục 5.
Lỗi này chỉ xuất hiện khi chạy npm run build, không thấy khi npm run dev.
Regex phân biệt dấu: "nuoc" không khớp "nước".
Giải pháp: thêm trường searchName không dấu vào mỗi sản phẩm:
function boDau(str) {
return str.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/đ/g, "d").replace(/Đ/g, "D");
}
Lưu searchName: boDau(name).toLowerCase() khi thêm/sửa sản phẩm,
rồi tìm trên trường đó.
Bạn dùng router.push trong useEffect với debounce — mỗi ký tự tạo một
mục trong lịch sử duyệt web. Dùng router.replace thay vì push (như code mục 5).
📝 Bài tập
-
Gợi ý tìm kiếm (autocomplete): hiện dropdown 5 sản phẩm khớp ngay dưới ô tìm kiếm khi gõ, dùng debounce 250ms.
-
Cuộn vô hạn (infinite scroll) cho trang
/menubằnguseSWRInfinitevàIntersectionObserver. -
Chuyển
/menusang server-side filter: dùng API mới với?category=...&minPrice=...&sort=...thay cho lọc trongProductlist. So sánh số byte tải về (tab Network) trước và sau. -
Lưu lịch sử tìm kiếm vào localStorage, hiện 5 từ khóa gần nhất.
-
Tìm kiếm không dấu: cài đặt trường
searchNamenhư mục "Xử lý sự cố", viết script cập nhật cho 12 sản phẩm hiện có. -
Thử tấn công ReDoS: tạm bỏ dòng
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), rồi gọi?q=(a+)+$. Quan sát CPU của tiến trình node. Sau đó thêm lại. (Chỉ thử trên máy cá nhân, không bao giờ thử trên server người khác.)
All rights reserved