Build Search with Highlighting
Learn how to filter a list of articles by a search query and highlight every occurrence of the match in both titles and body text. A common frontend interview question that tests regex, string splitting, and dynamic JSX rendering.
Table of Contents
Problem Statement
Build a search interface that filters a list of articles and highlights every occurrence of the search query in both the title and body text.
- Filter articles whose title OR body contains the search query
- Highlight all occurrences of the query in both title and body
- Matching should be case-insensitive
- If the query is empty, show all articles with no highlights
- Show the count of matching articles
- Handle special regex characters in the query safely
Why this question?
Search with highlighting tests string manipulation, regex fundamentals, and the ability to return mixed JSX (plain text + styled spans) from a function. It's deceptively simple — most candidates get filtering right but struggle with the highlighting part, especially escaping special characters.
Component Anatomy
The UI has three parts: a search input, a results count, and the article list.
Search Input
A controlled input that updates the query state on every keystroke. The query drives both filtering and highlighting.
Article Cards
Each card shows a title and body. Both are passed through the highlightText function which wraps matches in <mark> tags.
Filter Logic
A simple .filter() that checks if the title or body includes the query (case-insensitive). Runs on every render.
Highlight Function
The core piece: splits text around matches using a regex with a capturing group, then maps parts to plain text or highlighted spans.
┌─────────────────────────────────────────────┐ │ 🔍 Search articles... │ ← Controlled input ├─────────────────────────────────────────────┤ │ Showing 3 of 6 articles │ ← Filtered count ├─────────────────────────────────────────────┤ │ │ │ Getting Started with [React] │ ← [React] = highlighted │ [React] is a JavaScript library for... │ │ │ │ Understanding JavaScript Closures │ │ In JavaScript, closures are created... │ │ │ └─────────────────────────────────────────────┘
Filtering Logic
Filtering is the easy part. Check if the title or body contains the query, case-insensitively. If the query is empty, show everything.
const [query, setQuery] = useState(""); const filtered = ARTICLES.filter((article) => { if (!query.trim()) return true; // Empty query → show all const q = query.toLowerCase(); return ( article.title.toLowerCase().includes(q) || article.body.toLowerCase().includes(q) ); });
Why not debounce the filter?
For a small dataset (6 articles), filtering on every keystroke is fine. For larger datasets (1000+ items), you'd debounce the query or move filtering to a Web Worker. Mention this trade-off in interviews.
The Highlighting Technique
This is the core of the problem. The trick is using String.split() with a regex that has a capturing group. When you split with a capturing group, the matched parts are included in the result array.
Build a regex with a capturing group
Create new RegExp(`(${query})`, "gi"). The parentheses create a capturing group. The "g" flag finds all matches, "i" makes it case-insensitive.
Split the text
text.split(regex) returns an array of alternating non-match and match segments. For example: "React is great".split(/(react)/gi) → ["", "React", " is great"].
Map parts to JSX
Loop over the parts. If a part matches the query (case-insensitive compare), wrap it in a <mark> tag. Otherwise, return it as plain text.
const text = "React is a JavaScript library. React rocks."; const query = "react"; // Without capturing group — matches are LOST: text.split(/react/gi) // → ["", " is a JavaScript library. ", " rocks."] // With capturing group — matches are KEPT: text.split(/(react)/gi) // → ["", "React", " is a JavaScript library. ", "React", " rocks."] // ^^^^^ match ^^^^^ match
const highlightText = (text: string, query: string) => { if (!query.trim()) return text; // Escape special regex characters const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // Split with capturing group to keep matches const parts = text.split(new RegExp(`(${escaped})`, "gi")); return parts.map((part, i) => part.toLowerCase() === query.toLowerCase() ? ( <mark key={i} className="bg-yellow-200 text-gray-900 rounded-sm px-0.5"> {part} </mark> ) : ( part ) ); };
Why compare with toLowerCase()?
The regex splits case-insensitively, so the matched part preserves its original casing (e.g., "React" not "react"). We compare with toLowerCase() to identify which parts are matches regardless of case. This way the highlight shows the original text casing.
Escaping Special Characters
If the user types C++ or file.txt or (test), the regex will break because +, ., and () are special regex characters. You must escape them.
// These characters have special meaning in regex: // . * + ? ^ $ { } ( ) | [ ] \ const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // Examples: escapeRegex("C++") // → "C\+\+" escapeRegex("file.txt") // → "file\.txt" escapeRegex("(test)") // → "\(test\)" // Now safe to use in RegExp: new RegExp(`(${escapeRegex("C++")})`, "gi") // → /(C++)/gi ✓
| User Input | Without Escape | With Escape |
|---|---|---|
| C++ | ❌ Invalid regex error | ✓ Matches literal "C++" |
| file.txt | ❌ Matches "fileatxt" too (. = any char) | ✓ Matches only "file.txt" |
| (test) | ❌ Creates a capture group instead | ✓ Matches literal "(test)" |
This is an interview differentiator
Most candidates forget to escape special characters. Bringing it up proactively shows attention to edge cases and production-readiness. Interviewers notice this.
Full Implementation
Here's the complete search with highlighting. Study how filtering, escaping, splitting, and JSX mapping compose together.
"use client"; import { useState } from "react"; interface Article { id: number; title: string; body: string; } const ARTICLES: Article[] = [ { id: 1, title: "Getting Started with React", body: "React is a JavaScript library for building user interfaces...", }, // ... more articles ]; export default function HighlightSearchTextPage() { const [query, setQuery] = useState(""); // 1. Filter articles by query const filtered = ARTICLES.filter((article) => { if (!query.trim()) return true; const q = query.toLowerCase(); return ( article.title.toLowerCase().includes(q) || article.body.toLowerCase().includes(q) ); }); // 2. Highlight matches in text const highlightText = (text: string, search: string) => { if (!search.trim()) return text; // Escape special regex characters const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // Split with capturing group — keeps matches in the array const parts = text.split(new RegExp(`(${escaped})`, "gi")); return parts.map((part, i) => part.toLowerCase() === search.toLowerCase() ? ( <mark key={i} className="bg-yellow-200 text-gray-900 rounded-sm px-0.5" > {part} </mark> ) : ( part ) ); }; return ( <div> {/* Search input */} <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search articles..." /> {/* Results count */} <p>Showing {filtered.length} of {ARTICLES.length} articles</p> {/* Article list */} {filtered.map((article) => ( <div key={article.id}> <h2>{highlightText(article.title, query)}</h2> <p>{highlightText(article.body, query)}</p> </div> ))} </div> ); }
Case-insensitive matching
Both filtering and highlighting use case-insensitive comparison. The original casing is preserved in the rendered output.
Regex escaping
Special regex characters in the query are escaped before building the RegExp, preventing crashes on inputs like 'C++' or '(test)'.
Debounced search
For large datasets, debounce the query update by 200-300ms to avoid filtering and re-rendering on every keystroke.
Fuzzy matching
Instead of exact substring matching, use a fuzzy search algorithm (like Fuse.js) to match misspellings and partial words.
Common Interview Follow-up Questions
After building search with highlighting, interviewers explore edge cases and enhancements:
Q:Why use split with a capturing group instead of replaceAll?
A: replaceAll returns a string, but we need JSX — a mix of plain text and <mark> elements. split() with a capturing group gives us an array of parts that we can map to JSX nodes. You can't embed React elements inside a string.
Q:What if the dataset has 10,000 articles?
A: Debounce the search input (300ms). Move filtering to a Web Worker to avoid blocking the main thread. Paginate or virtualize the results list. Consider a server-side search with an index (like Elasticsearch) for very large datasets.
Q:How would you highlight multiple search terms?
A: Split the query by spaces into individual terms. Build a regex that matches any of them: new RegExp(`(${terms.join('|')})`, 'gi'). The same split-and-map technique works — just check if each part matches any of the terms.
Q:How would you handle overlapping matches?
A: With simple substring matching, overlaps don't occur because split() processes left to right. For regex patterns with alternation (a|ab), the regex engine picks the first match. If you need longest-match-first, sort terms by length descending before joining with |.
Q:What about performance of creating RegExp on every render?
A: For small datasets it's negligible. For optimization, memoize the regex with useMemo keyed on the query. You could also memoize the filtered + highlighted results. But premature optimization here is usually not worth it — profile first.
Q:How would you add keyboard shortcuts (e.g., Ctrl+F to focus search)?
A: Add a useEffect with a keydown listener on the document. Check for e.ctrlKey && e.key === 'f', call e.preventDefault() to override browser find, and focus the search input using a ref. Clean up the listener on unmount.
Q:What about accessibility for highlighted text?
A: Use the <mark> HTML element — it has semantic meaning ('highlighted text'). Screen readers announce it. Add aria-label on the results count to announce 'Showing X of Y results'. Use role='status' with aria-live='polite' so screen readers announce count changes.
Ready to build it yourself?
We've set up the articles and UI shell. Implement the filtering and highlighting logic from scratch.
Built for developers, by developers. Happy coding! 🚀