Most "add search to WordPress" tutorials stop at dropping a widget in the header. That's fine until the site has gated content — membership videos, paid courses — and you realize the default search will happily leak the titles of member-only material to logged-out visitors. That's the interesting part of this build, and the part the tutorials skip.
This is a walkthrough of a live search feature I built for a fitness-membership platform running WordPress, WooCommerce, Divi, LearnDash, and WishList Member. The stack matters, because every one of those plugins had an opinion about how search should behave. The final result: an icon-triggered modal with debounced live results, grouped by content type, access-aware, styled to the brand, and working on mobile.
The shape of the problem
The site had four distinct content types worth searching, each with different access rules:
- Blog posts — public
- On-Demand videos — membership-gated
- Courses and lessons — gated via LearnDash + WishList Member
- Store products — public WooCommerce
So the real architectural decision wasn't the UI. It was whether to run one unified index with access-aware filtering, or scope search per content type. I went with unified, enforcing gating at query time so a logged-out user never sees the title or excerpt of member-only content bleeding through results. That single decision drove most of the backend design.
The UI decision was quicker. The navbar was already dense, so an inline expanding input would have felt cramped at the breakpoint where the menu collapses. An icon that opens a full-screen overlay with live results sidesteps the space problem entirely and reads as more polished.
The backend: one REST endpoint
Everything runs through a single custom REST route. This lives in the child theme's functions.php — deliberately not in a snippets plugin, because search is a hot path and shouldn't depend on a plugin's eval loader staying healthy.
add_action('rest_api_init', function () {
register_rest_route('rev6/v1', '/search', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'args' => [
's' => ['sanitize_callback' => 'sanitize_text_field', 'required' => true],
],
'callback' => function (WP_REST_Request $req) {
$term = $req->get_param('s');
if (mb_strlen($term) < 2) return rest_ensure_response([]);
$args = [
's' => $term,
'post_type' => ['post', 'product', 'sfwd-courses'],
'posts_per_page' => 8,
'no_found_rows' => true,
];
if (function_exists('relevanssi_do_query')) {
$args['relevanssi'] = true;
$q = new WP_Query($args);
relevanssi_do_query($q);
} else {
$q = new WP_Query($args); // fallback: core search
}
$out = array_map(fn($p) => [
'title' => get_the_title($p),
'url' => get_permalink($p),
'type' => get_post_type($p),
'thumb' => get_the_post_thumbnail_url($p, 'thumbnail'),
], $q->posts);
return rest_ensure_response($out);
},
]);
});
The function_exists('relevanssi_do_query') guard is doing more than it looks. Relevanssi is the search plugin that replaces WordPress's weak default search with a proper relevance-ranked index that respects membership access. But wrapping the call in a capability check means the feature degrades gracefully to core WP search if the plugin is inactive, missing, or not yet loaded in a given request context. During the build, an unguarded relevanssi_do_query() call was fataling the endpoint with a white screen — the guard turns that into a working (if less clever) fallback instead of a broken site.
That's the general principle: a search endpoint should never be able to take down the page. Degrade, don't die.
The access-leak problem
Here's the part worth dwelling on. Relevanssi indexes what you tell it to, and by default it will index gated content and return it in results. On a membership site, that means a logged-out visitor searching for a topic covered only in a paid course sees that course's title and excerpt in the results. That's a content leak — you're advertising exactly what's behind the paywall, with the specifics.
The fix is enforced server-side, in two layers:
- Configure Relevanssi's index so protected post types respect the membership plugin's visibility rules.
- Where the plugin's filtering isn't clean, add a
relevanssi_post_okfilter to drop posts the current user can't access before they ever reach the response.
The critical rule: never filter this in JavaScript. The overlay hiding a result client-side means the data already left the server — it's in the network response, visible to anyone who opens devtools. Access control that lives in the frontend isn't access control. It has to happen before rest_ensure_response.
This also interacts with caching, which I'll come back to.
The frontend: debounced, aborted, escaped
The client is vanilla JS — no framework needed for this. It's enqueued from the child theme with the endpoint URL localized in, rather than hardcoded, so it survives sites where the REST root isn't at the default path.
let ctrl;
const search = debounce(async (term) => {
if (term.length < 2) return (results.innerHTML = '');
ctrl?.abort(); ctrl = new AbortController();
results.innerHTML = '<div class="rev6-loading"><span class="rev6-spinner"></span>Searching…</div>';
try {
const res = await fetch(`${REV6_SEARCH.endpoint}?s=${encodeURIComponent(term)}`,
{ signal: ctrl.signal });
render(await res.json());
} catch (e) {
if (e.name !== 'AbortError') {
console.error(e);
results.innerHTML = '<p class="rev6-empty">Something went wrong. Try again.</p>';
}
}
}, 250);
Three things are load-bearing here:
Debounce (250ms). Without it, every keystroke fires a request. On a slow query that's a pile-up of in-flight fetches racing each other.
AbortController. When the user keeps typing, the previous request is cancelled. This prevents the classic race where an earlier, slower query resolves after a later one and overwrites the correct results with stale ones. The if (e.name !== 'AbortError') check keeps those intentional cancellations out of the error path.
Explicit error state. A failed fetch used to leave the spinner spinning forever. On a search that can take a couple of seconds, timeouts and dropped requests are likely enough that silent failure is a real UX hole. Now it shows a message and stops.
The render step groups results by content type so "Articles / Store / Courses" read as sections rather than a flat, confusing list. And the titles get escaped before injection:
const escapeHtml = s => s.replace(/[&<>"']/g, c =>
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
This is not optional. The response strings go into innerHTML, so an unescaped post titled with an <img onerror=...> payload would be stored XSS. Escape anything from the API before it touches the DOM as HTML.
The loading state, and the thing it was hiding
Adding a spinner was a requested UX fix — the modal sat blank for a beat on slower queries and read as broken. But a spinner treats the symptom. The real question was why the query took seconds.
A properly indexed Relevanssi query across a few post types should return in well under 200ms. Multi-second responses almost always mean one of:
- The index needs rebuilding — a stale or partial index is the number-one cause.
- The query is silently falling through to the core WP fallback, which does an unindexed
LIKEacross the post table. On a large table, that genuinely is slow.
So the spinner shipped, but the actual fix was confirming Relevanssi was active and its index freshly built. Worth checking the endpoint's response time in the network tab before accepting "a bit slow" as normal — the spinner should be covering a 150ms gap, not a 3-second one.
Caching: the membership leak, again
The site sits behind a Varnish + page-cache stack. This creates a subtle and serious failure mode: if the search endpoint gets cached, one user's access-filtered results can be served to a different user. A logged-in member's search, cached, then served to a logged-out visitor — leaking exactly the gated content the server-side filtering was designed to protect.
The fix is to exclude the REST route from the page cache entirely. The access filtering only works if every request is evaluated fresh against the current user. This is the same leak from the backend section, wearing a different hat — and it's why the whole "never trust the client, evaluate server-side" principle has to extend to the cache layer too.
Mobile, and a lesson about fighting the theme
Desktop was straightforward. Mobile was a grind, and the lesson is more useful than the code.
The trigger started life injected into the nav menu via wp_nav_menu_items. On desktop, fine. But when Divi collapsed the menu into a hamburger, the injected button got swallowed into the dropdown and — worse — became unclickable. Divi binds its own touch handlers to the collapsed menu items, and they intercepted the tap before the button's handler could run. I tried stopPropagation, z-index bumps, scoped CSS. Each one was a patch on a symptom.
The actual fix was to stop fighting the theme's menu. Instead of living inside the hamburger, the mobile trigger gets injected as a standalone icon in the header, outside the menu entirely:
function mountMobileTrigger() {
if (document.querySelector('#rev6-search-mobile')) return;
const header =
document.querySelector('#main-header .container') ||
document.querySelector('#main-header') ||
document.querySelector('.et-l--header .container') ||
document.querySelector('header');
if (!header) { console.warn('[search] header not found'); return; }
const btn = document.createElement('button');
btn.id = 'rev6-search-mobile';
btn.setAttribute('aria-label', 'Search');
btn.innerHTML = '<svg viewBox="0 0 24 24" width="22" height="22" ...></svg>';
btn.addEventListener('click', openOverlay);
header.appendChild(btn);
}
Because it's no longer a child of the menu, none of Divi's collapsed-menu handlers can eat the tap. It also fixed the real UX problem — search was one tap from any page instead of buried at the bottom of an expanded menu.
The general takeaway: when a page builder's component keeps resisting your changes, the move is usually to stop modifying its component and place your own element beside it, not inside it. Chained patches against a theme's behavior are a signal you're solving the problem at the wrong layer.
A couple of smaller mobile details that mattered: the input font-size is set to 16px, because anything smaller triggers iOS Safari's auto-zoom on focus. And the modal is a contained card with side margins and a capped height rather than a full-bleed sheet — a preference call, but it reads as a modal rather than a takeover.
What I'd tell someone building this
- Decide your gated-content policy first. Fully invisible to non-members, or visible-but-locked? That one answer drives the entire index configuration. Everything else is downstream of it.
- Access control is server-side or it doesn't exist. Filter before the response, exclude the endpoint from cache, and never rely on the UI to hide anything sensitive.
- Make search un-crashable. Guard optional dependencies, fall back to core, and give it an explicit error state.
- Debounce and abort. They're four lines that eliminate whole categories of race conditions and wasted requests.
-
Escape everything from the API.
innerHTMLplus unescaped strings is stored XSS waiting to happen. - Stop fighting the page builder. If you're on your third patch against a theme's component, put your element next to it instead of inside it.
The feature itself is small — an icon, an overlay, an endpoint. But most of the engineering was in the parts you can't see: the access filtering, the cache exclusion, the race handling, the graceful degradation. That's usually where the real work is.