Collaborate, Innovate, Automate

Building a Multilingual Custom Search Page with PnP Modern Search

Part 2 — Verticals, Filters, and Card Templates

Part 1 of this series covered the information architecture and prerequisites for a multilingual PnP Modern Search implementation. This part covers the actual build — adding and configuring the web parts, connecting them together, scoping results by content type, and building the card templates that control how results are displayed.

Note: This is a technical companion page to the PnP Modern Search multilingual blog series. Part 1 covers IA and prerequisites. Part 3 covers the multilingual tag resolution layer — managed property mapping, search schema configuration, and the taxId crawled property behaviour.

The Web Parts

A custom PnP Modern Search page is built from a small set of web parts that work together:

The three web parts added to the page, showing verticals across the top, filters down the left, and results in the main column

These three web parts need to be connected to each other so that selecting a vertical or filter actually changes what the results web part displays.


Adding and Connecting the Web Parts

Add all three web parts to the page in the layout you want — typically verticals across the top, filters down the left, results in the main column.

Connecting Filters to Results

Edit the Search Results web part, go to the connections configuration, and select your Search Filters web part as the data source. This ensures that when a user selects a filter value, the results web part receives the refinement and updates.

Search Results web part connections configuration, showing Search Filters selected as the data source

Connecting Verticals to Results

Same pattern — the Search Results web part needs to receive its query context from the Search Verticals web part so that switching tabs changes the scope of results shown.

Web part connections configuration panel, showing Search Results connected to Search Filters and Search Verticals

Configuring Verticals

Each vertical needs its own query template that scopes results to particular content types. For an "All", "Policies", and "Process Manual" setup, you'd configure three verticals.

Scoping by Content Type

The two options are ContentType (the display name) or ContentTypeId (the permanent identifier). ContentTypeId is the more stable choice — content type display names can be renamed by an editor without anyone realising the search scope will silently break, whereas the ID never changes.

To find a content type's ID, open the list or library settings, click into the content type, and the ID appears in the URL as ctype=0x.... Or retrieve it via PnP PowerShell:

Get-PnPContentType -List "Site Pages" | Select-Object Name, Id

The query template for a vertical scoped to Policy content uses a wildcard match on the ID, which also captures any content types that inherit from it:

ContentTypeId:0x0101009D1CB255DA76424F860D91F20E6C41180071*

In this case the All vertical includes all content types in the query template. The base template will be applied to the most generic. Other cards will highlight certain content types.


Configuring the Filters Web Part

Each filter maps to a managed property — for example, Function maps to RefinableString00 in our setup. The checkbox filter template has consistently switched labels correctly when ?Locale=fr-FR is appended to the URL, in our testing.

Note: There's a cache refresh control buried in the filter's settings panel that can help if filter values appear stale after a managed property mapping change.

Slots — and Why You May Not Need Them

PnP Modern Search pre-configures a set of standard slots out of the box — Title, Path, FileType, and a handful of others. If your card template only needs these standard fields, you don't need to configure any custom slot mapping at all. Reference them directly in your Handlebars template as {{slot item @root.slots.Title}} and so on.

Slot mapping only becomes necessary if you want to expose additional managed properties — like your RefinableString columns — through the slot system specifically. In this build, we didn't use them at all. Instead, the RefinableString value is referenced directly in the Handlebars template:

{{#each (split (slot item @root.slots.Tags) ";") limit=2}}
    <span class="card-pill">{{trim (last (split this "|"))}}</span>
{{/each}}

This is a simpler approach for a custom card template where you're writing the Handlebars yourself rather than relying on the out-of-the-box card designer.


The Card Templates

Three card templates were built for this implementation, each visually distinguishing a different content type:

The three card templates side by side — white base card, purple-bordered Policy card, green-bordered Process Manual card
The Policy vertical's query template configuration, scoping results to the Policy content type
Template Used For Visual Treatment
Base The "All" vertical and any content type without a dedicated visual treatment White card, blue pill
Policy Policy content Purple left border
Process Manual Process manual content Green left border

The Policy and Process Manual templates use a coloured left border rather than a different layout — this keeps visual consistency across the result set while still making content type identifiable at a glance.

Title Wrapping

By default, card titles may not wrap correctly if the underlying CSS doesn't account for longer titles. The fix is straightforward — ensure the title element allows normal text wrapping rather than truncating or overflowing:

.card-title {
    white-space: normal;
    overflow: visible;
    word-break: break-word;
}

Adding a Summary/Description

To surface a short description beneath the title, map a Summary slot to the AutoSummary or Description-mapped managed property in the data source properties, then reference it in the template with a clamp to limit it to a fixed number of lines:

{{#if (slot item @root.slots.Summary)}}
<div class="card-summary">
    {{slot item @root.slots.Summary}}
</div>
{{/if}}
.card-summary {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    word-break: break-word;
}
Note: The {{#if}} guard prevents an empty gap appearing on cards that have no summary value.

Tag Pills

The pill display logic splits a semicolon-delimited multi-value managed property, takes a limited number of values, and extracts just the label portion (discarding any GUID or path prefix that the raw value might carry):

<div class="card-pills">
    {{#each (split (slot item @root.slots.Tags) ";") limit=2}}
        <span class="card-pill">{{trim (last (split this "|"))}}</span>
    {{/each}}
</div>

Result Types and Inline Snippets

Within the "All" vertical, result types allow different card templates to render automatically based on the content type of each individual result — so a Policy item shows the purple-bordered card and a Process Manual item shows the green-bordered card, all within the same mixed result list.

Choose Custom as the card type, then add a link to the base card template — these are normally stored in Site Assets.

This is configured via the resultTypes Handlebars helper wrapping the card markup, with the result type rules defined separately in the web part's result types configuration, matched by content type or another managed property condition.

Mixed result list in the All vertical, showing Policy and Process Manual cards rendering with their distinct templates side by side
Result types configuration mapping content types to their corresponding card templates
{{#> resultTypes item=item}}
    <li>
        <!-- card markup -->
    </li>
{{/resultTypes}}

Connecting the Page to Site Search

Building the page is only half the job — SharePoint needs to be told to actually use it when someone searches from the standard search box anywhere on the site, rather than only working when visited directly.

Setting the Custom Search Results Page

Go to Site Settings → Search Settings (for a single site) or Site Collection Settings → Search Settings (to apply it across the whole site collection). Under "Configure search results page settings," enter the URL of your custom search page for both:

Search Settings page showing the Site Search Results Page and Site Collection Search Results Page URL fields

Updating the Query Text to Read from the Page Environment

By default, a newly added Search Results web part only responds to its own on-page search box, ignoring whatever was typed into the header search box that redirected the user there in the first place. To fix this, the web part's query text needs to read from the URL query string parameter that SharePoint passes through on redirect.


What's Covered Here vs What Comes Next

Everything in this page uses the current, working approach:

This is a fully functional custom search experience as it stands.

What it does not yet cover is the multilingual tag resolution — getting the ows_taxId_ crawled properties to correctly populate their mapped RefinableString managed properties so that filter pills display translated labels rather than the default English value. That diagnostic process, and the eventual move to managed card fields once the search schema mapping is resolved, is covered in Part 3.

Note: Part 3 coming soon!

Related Scripts