The Architecture Behind a Search Page That Still Feels Fast at Scale
Building a global search page for a large WordPress platform wasn’t mainly a frontend task. It required a dedicated search index, a clear content model, a controlled API, and a UI that could handle filters without feeling slow.
A search page can look like a small frontend feature.
You add an input, place a few filters in a sidebar, render a list of results, and call it done. That works when the website has a few dozen pages and every piece of content follows the same structure.
This project was nothing like that.
The website had grown into a large content platform with news, publications, events, training material, country profiles, newsletters, blog posts, normal pages, and several custom content types. Some fields lived in the WordPress editor. Others came from custom fields. Publications could also include PDF documents whose text needed to be searchable.
The first request sounded simple: build one global search page for all of it.
The real work was deciding what “all of it” meant, how that content should reach the search engine, and how the UI could stay responsive without asking WordPress to solve problems it wasn’t built to solve.
The search page wasn’t the system
My first important decision was to stop thinking about the search page as the main feature.
The page was only the final layer. Behind it, the system needed five clear parts:
WordPress content
|
v
Indexing and normalization
|
v
Apache Solr
|
v
Search API
|
v
React search interface
WordPress remained the source of truth. Editors could keep working with the tools they already knew.
Solr became the read model for search, while a custom API sat between Solr and the frontend.
That separation mattered.
It meant the React application didn’t need to understand WordPress tables, custom fields, or Solr query syntax. It only had to understand a stable search response.
Why native WordPress search wasn’t enough
WordPress search is fine for many websites. I still use it when the content model is small and the requirements are basic.
Here, the cracks appeared quickly.
A normal WordPress search mostly works with post titles and content. Once custom post types, taxonomies, custom fields, date ranges, PDF text, weighted relevance, and facets enter the picture, the database query becomes much harder to control.
Custom fields are a particular problem. WordPress stores much of that data in wp_postmeta, which is flexible but not a pleasant base for a large faceted search system.
Filtering several fields can create multiple joins against the same table. Add text search, ordering, pagination, and totals, and the query starts doing far more work than the UI suggests.
I didn’t want each visitor’s search to rebuild that complexity in MySQL.
Solr changed the model. Instead of asking WordPress to assemble the answer at request time, I could prepare a search document when content changed. Queries then ran against fields designed for search.
This moved work away from the user’s request and into the indexing process.
Designing the search document
The next challenge was deciding what one indexed item should look like.
Each WordPress content type had its own fields, but the search UI needed a shared shape. An event might have start and end dates. A publication might have authors and a PDF. A country profile might have a region or country field. A normal page might only have a title, body, and URL.
Trying to expose every field from every content type would’ve made the index messy and the frontend harder to maintain.
I created a common document model instead:
{
"id": "post-4821",
"content_type": "publication",
"title": "Example publication title",
"excerpt": "A short description used in search results.",
"body": "Normalized searchable content...",
"url": "https://example.org/publications/example-title",
"published_at": "2026-05-14T09:00:00Z",
"image_url": "https://example.org/media/cover.jpg",
"topics": ["media literacy", "research"],
"countries": ["Greece"],
"pdf_text": "Extracted text from the attached document..."
}
Not every field exists on every document. That’s fine. The important part is that shared fields mean the same thing everywhere.
The mapping step also gave me a place to clean content before it reached Solr. I could strip shortcodes, remove leftover HTML, normalize dates, turn taxonomy terms into plain arrays, and skip internal fields that had no search value.
A simplified mapper looked something like this:
function build_search_document(WP_Post $post): array
{
$document = [
'id' => sprintf('post-%d', $post->ID),
'content_type' => $post->post_type,
'title' => get_the_title($post),
'body' => wp_strip_all_tags(
strip_shortcodes($post->post_content)
),
'url' => get_permalink($post),
'published_at' => get_post_time('c', true, $post),
];
$document['topics'] = wp_get_post_terms(
$post->ID,
'topic',
['fields' => 'names']
);
// The UI expects one field, even though each content type stores this differently.
$document['excerpt'] = resolve_search_excerpt($post);
return apply_filters('global_search_document', $document, $post);
}
The actual mapping grew over time. That was expected.
Content models rarely stay as clean as they look in the first diagram.
Full sync and incremental updates
A search index that is fast but stale isn’t very useful.
I needed two ways to keep Solr in sync with WordPress.
The first was a full sync. It indexed every supported content type and was useful during the initial setup, after schema changes, or when an indexing bug had affected existing documents.
I exposed this through an admin tool and a command-line command. The process worked in batches so it didn’t try to load the whole website into memory.
The second was incremental sync.
When an editor published or updated an item, WordPress rebuilt that document and sent it to Solr. When an item moved to draft or was deleted, its matching Solr document had to disappear as well.
The happy path was easy. The edge cases took longer.
WordPress fires save hooks more than once in some editing flows. Autosaves and revisions shouldn’t trigger a real indexing request. Bulk edits behave differently. A post can change status without changing its content.
Custom fields may also save after the main post hook. Indexing too early can send an old value to Solr.
That led to checks like these:
add_action('save_post', function (int $post_id, WP_Post $post): void {
if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
return;
}
if (!in_array($post->post_type, supported_search_types(), true)) {
return;
}
// Some custom fields aren't ready during the first save_post callback.
// Queueing avoids indexing a half-saved document.
queue_search_sync($post_id);
}, 20, 2);
Queueing the update also made failures easier to retry. A content save shouldn’t fail just because Solr had a short outage.
This introduced eventual consistency. A new article might take a few seconds to appear in search.
That trade-off was acceptable. Blocking the editor until a remote indexing request finished would’ve been worse.
Keeping Solr behind an API
It can be tempting to let the frontend query Solr directly. It removes one layer and looks quicker during a prototype.
I didn’t choose that route.
Direct access would expose Solr to the public network and couple the UI to its query language. It could also allow a browser to send expensive or unsupported parameters unless every part of the Solr configuration was locked down carefully.
Instead, I added a custom WordPress REST endpoint.
The endpoint accepts a small set of public parameters, such as the search term, content type, topic, country, date range, page, and page size. It validates them, builds the Solr query, and returns a response made for the UI.
A request could look like this:
GET /wp-json/global-search/v1/items
?q=media+literacy
&content_type=publication
&country=Greece
&page=1
&per_page=12
The API takes care of field weighting, query escaping, Solr filters, allowed facet fields, highlighting, pagination limits, and response mapping.
The frontend receives a smaller and more stable contract:
{
"items": [],
"total": 238,
"page": 1,
"per_page": 12,
"facets": {
"content_type": {
"publication": 81,
"event": 34,
"news": 123
},
"country": {
"Greece": 19,
"Germany": 27
}
}
}
This boundary became one of the most useful parts of the architecture.
Solr fields could change without forcing the React application to change at the same time. The API could translate the internal schema into the response the UI already expected.
Relevance needed product decisions
Search speed is easy to measure. Search quality is more subjective.
A result with the search term in its title should usually rank above one where the same term appears once near the bottom of a long PDF. Recent content might matter, but it shouldn’t always beat an older item with a much stronger text match.
That meant relevance couldn’t be left to a default query.
I used different weights for different fields. Titles received the strongest boost, followed by excerpts, body content, taxonomy terms, and extracted PDF text. Exact phrase matches received an extra boost.
The shape was close to this:
title^6
excerpt^3
topics^2.5
body^1.5
pdf_text^0.6
Those numbers weren’t scientific.
They came from testing real queries, looking at surprising results, and adjusting the balance. A search result that looked correct in theory could still feel wrong when used with the terms visitors were likely to enter.
That part is easy to underestimate. A search system can respond in 40 milliseconds and still feel broken if the first five results make no sense.
Facets should come from the same query
The UI needed filters for content type, topic, country, and other fields. Each filter also had to show how many matching items existed.
One approach would’ve been to fetch the results and then send separate requests for each set of filter counts.
I avoided that.
Solr can calculate facet counts as part of the same search operation while respecting the active query and filters. This kept the interface consistent. When a user selected a country, the other counts changed to reflect the remaining result set.
There was still a small product decision to make.
Should the selected filter’s own counts remain broad, so users can switch to another value? Or should every count reflect all active filters, including the selected one?
Both options are valid.
I chose the behavior that made it easier to move between filters without clearing the current selection first. The API handled that logic so the React components stayed simple.
The React interface had its own performance problems
Once Solr and the API were working, the frontend felt like the easy part.
It wasn’t.
A fast backend can still produce a poor search experience if the browser sends a request on every keystroke, renders stale responses, loses filter state on refresh, or replaces the whole page with a spinner whenever anything changes.
I treated the URL as the source of truth for the current search.
Query text, filters, page number, and sorting lived in search parameters. That meant users could refresh, share a link, or use the browser’s back button without losing their place.
Requests were debounced, and older requests were cancelled when a new search started:
const SEARCH_API_URL = import.meta.env.VITE_SEARCH_API_URL;
let activeController: AbortController | null = null;
export async function fetchSearchResults(
params: URLSearchParams
): Promise<SearchResponse> {
activeController?.abort();
activeController = new AbortController();
const response = await fetch(
`${SEARCH_API_URL}?${params.toString()}`,
{ signal: activeController.signal }
);
if (!response.ok) {
throw new Error(`Search request failed with ${response.status}`);
}
return response.json();
}
Cancellation fixed a subtle bug.
Without it, a slow response for an older query could arrive after a newer one and replace the correct results.
I also kept the previous result list visible while the next page or filter was loading. A small progress state feels much calmer than clearing the whole screen after every click.
The application was built and deployed separately from WordPress, then embedded into the search page. That added some integration work, but it kept the frontend release cycle independent from the theme.
It also reduced the risk of style and script conflicts with older plugins.
Pagination is not only a UI choice
Traditional page numbers were useful because users understood them and the content editors expected them.
Solr supports normal offset pagination with start and rows, which works well for the first pages. Deep pages become more expensive because the search engine still needs to find and sort all earlier matches.
For the public interface, I kept numbered pagination but limited how deep it could reasonably go.
Most users refine their query long before reaching page 100.
For tasks that need to walk through a very large result set, such as exports or background processing, cursor-based pagination is the safer choice. That’s a different use case and shouldn’t force the public UI into an unfamiliar pattern.
The architecture made room for both.
PDF search needed a clear limit
Some publications included useful information only inside attached PDFs. Indexing that text made the global search far more useful, but it also raised a question.
How far should document processing go?
I kept the first version focused on text-based PDFs. Their content could be extracted and added to the pdf_text field during indexing.
Scanned documents were different. They would’ve required OCR, extra processing time, more failure cases, and a way to judge extraction quality.
That was outside the scope of the first release.
This is one of those boundaries that makes a project healthier. “Search PDFs” sounds like one feature, but text extraction and OCR are two different systems.
Treating them as one would’ve added a lot of work without making the first release better.
Operations mattered as much as code
I didn’t want the search system to depend on someone remembering a command on a server.
The WordPress plugin included settings for the Solr connection, supported content types, batch size, and index options. It also exposed connection checks, sync controls, and logs for failed documents.
The command line still mattered.
Full reindexing is easier to monitor through a CLI, especially when a site contains thousands of items. Cron support also helped recover from missed incremental updates.
Those tools weren’t visible to users, but they made the system maintainable after the first deployment.
Without them, every indexing issue would’ve become a developer task.
What actually made it feel fast
Solr was a major part of the answer, but it wasn’t the whole answer.
The search felt fast because each layer did less work.
WordPress prepared content when it changed instead of building complex search queries for every visitor. Solr searched fields built for that job. The API allowed only the parameters the product needed.
React cancelled stale requests, kept state in the URL, and avoided blanking the interface during small updates.
None of those decisions is dramatic on its own.
Together, they changed the shape of the feature. The search page stopped being a heavy WordPress template and became a small client over a dedicated read model.
The trade-offs were real
This setup is more complex than a normal WordPress search form.
There is another service to run. The index can become stale. Schema changes may require a full reindex. Logs and retry paths need attention. Developers have to understand the content model in both WordPress and Solr.
For a small marketing site, I wouldn’t build this.
For a content platform with many content types, large archives, custom filters, and documents, the extra moving parts started paying for themselves.
Queries became predictable. Relevance could be tuned. New filters didn’t require another expensive SQL join. The frontend could evolve without being tied to the WordPress theme.
That was the real goal.
Not to make search technically impressive, but to make it boring for the user. They type, filter, and get useful results without thinking about the systems behind the page.
A good search architecture disappears like that.