Collaborate, Innovate, Automate

Building a Featured News Layout with PnP Modern Search and SPFx

A PnP Modern Search Extensibility Library: Editorial Hero Selection, Directorate Filtering, Content Ownership, and Share-by-Link

News rollups on a SharePoint intranet default to a strict chronological feed, newest first. It can mean a genuinely important story gets buried the moment someone posts something else five minutes later. This project takes a different approach: a custom PnP Modern Search extensibility library that gives editors a deliberate way to say "this is the one people should see first", a hero-plus-tiles layout, driven by a deliberately selected Yes/No field.

It's a standalone extensibility library, built the same way as the multilingual search cards project, its own SPFx solution, its own deployment, living on its own page, but it borrows and extends several patterns. The same clickable, locale-aware taxonomy filter pills, and the same content-owner-reporting mechanism, adapted here for news articles.

Prerequisite: This page assumes the same PnP Modern Search extensibility architecture described in the multilingual search series, a separate SPFx project implementing IExtensibilityLibrary, contributing a custom layout, custom web components, and custom Handlebars helpers to a Results web part.

What This Adds

Featured News hero-plus-tiles layout, showing one large featured story alongside three supporting tiles

Step 1

Editorial Control Over "Featured"

The hero slot is driven by a plain Yes/No site column, FeaturedNews, applied to the Site Page content type. An editor ticks the box on whichever article should lead; nothing else about how the article is authored or promoted to News changes.

Hero selection itself happens entirely in a custom Handlebars block helper, registered by the extensibility library:

handlebarsNamespace.registerHelper('withFeaturedHero', function (
  this: unknown,
  items: any[],
  options: Handlebars.HelperOptions
) {
  if (!items || items.length === 0) {
    return '';
  }
  const hero = items.find((item) => isFeatured(item)) || items[0];
  return options.fn(hero, { blockParams: [hero] });
});

The underlying search query returns articles sorted newest-first; the helper simply walks that list and picks the first item flagged as featured. If nothing is currently flagged, it falls back to the single most recent article overall, the hero slot is never empty, and an editor doesn't have to remember to flag something for the layout to make sense.

A companion helper, eachNonHero, renders whatever's left as tiles, re-running the exact same selection logic internally, so the two helpers can never disagree about which item is the hero, and capped to a fixed number of tiles regardless of how many articles the underlying query returns:

{{#withFeaturedHero data.items as |item|}}
  <!-- hero markup -->
{{/withFeaturedHero}}

{{#eachNonHero data.items 3 as |item|}}
  <!-- tile markup -->
{{/eachNonHero}}

Because both helpers work from whatever the search query currently returns, demotion is automatic: the moment an editor flags a newer article, it becomes the new hero on the very next page load, and the previous hero, no longer the first flagged item in the list, simply appears as a tile instead. Nothing needs to be manually re-ordered or cleared.


Step 2

A Clickable Directorate Filter

Each card carries a single pill showing which Directorate the article belongs to, Finance, Information Technology, Human Resources, and so on, sourced from a managed metadata column (OrgGroups) mapped to a term-store-aware managed property, the same RefinableStringNN/AutoRefinableStringNN pairing used throughout the multilingual search series: the raw field carries the full taxonomy reference, while the Auto-prefixed version resolves to the correct display label for the current locale automatically.

The pill isn't just a label, clicking it reloads the page with the Filters web part's Directorate filter pre-applied, using the same clickable-filter mechanism built for the search cards project. A shared Handlebars helper builds the encoded filter-state URL the Filters web part expects for a taxonomy-backed field:

handlebarsNamespace.registerHelper('taxonomyFilterQuery', (
  rawRefinableString: string,
  label: string,
  filterName: string
) => {
  const parts = rawRefinableString.split(';');
  const gp0Part = parts.find((p) => p.startsWith('GP0|#'));
  const l0Part = parts.find((p) => p.startsWith('L0|#'))?.split('|').slice(0, 2).join('|');

  const filterState = [{
    filterName,
    values: [{ name: label, value: `or(${gp0Part},${l0Part})`, operator: 0 }],
    operator: 'or',
    hideNodesNotInDataSet: true,
    expandAllNodesByDefault: false
  }];

  return encodeURIComponent(JSON.stringify(filterState));
});
<a href="?f_{{@root.filters.instanceId}}={{taxonomyFilterQuery item.RefinableString113 item.AutoRefinableString113 'RefinableString113'}}"
   class="news-card-pill">
    {{item.AutoRefinableString113}}
</a>

Because the hero and tiles both derive from the same underlying data.items, filtering by Directorate doesn't just narrow the tile grid, it re-runs the hero selection too. If the current hero doesn't match the filter, a different article naturally takes its place; there's no separate logic to keep the two in sync.


Step 3

Reporting Outdated or Incorrect Content

Every card carries a small flag icon that opens a lightweight "Report content issue" dialog, letting a reader flag an article to whoever's responsible for it, without needing to know who that is themselves.

The component looks up the article's ContentOwner, a Person/Group column on Site Pages, via a direct PnP JS call scoped to the specific list item:

const item = await list.items.getById(parseInt(itemId, 10))
    .select("ContentOwner/Title", "ContentOwner/EMail")
    .expand("ContentOwner")();

If an owner is found, the dialog offers an optional free-text note and sends a plain email via Microsoft Graph on confirmation. If no owner is set, the dialog says so plainly rather than failing silently. There's no logging, no tracking list behind it.


Step 4

Sharing an Article, Without Granting Access

The share button solves a narrower, different problem: a reader wants to point a specific colleague at an article, by name, without either of them touching permissions.

The recipient is chosen through a real, validated People Picker (@pnp/spfx-controls-react), rather than a free-text email field, so a reader searches for a colleague by name and picks a real, resolved person, with no risk of a mistyped address silently failing to deliver:

<PeoplePicker
    context={peoplePickerContext}
    titleText="Share with"
    personSelectionLimit={1}
    principalTypes={[PrincipalType.User]}
    onChange={this.onPeopleChange}
/>

Sending is a plain Microsoft Graph email, addressed to whoever was selected, with an optional personal note and a link to the article:

await client.api('/me/sendMail').post({
    message: {
        subject: `Sharing: ${title}`,
        body: { contentType: 'Text', content: messageLines.join('\n') },
        toRecipients: [{ emailAddress: { address: selectedPerson.email } }]
    }
});
Important: this button only sends a link, it does not grant the recipient any access to the page. If they don't already have permission to view it, the email simply won't help them; nothing about their access changes.

The End Result

A News rollup that puts an editor's judgment ahead of the clock, without losing anything a reader would expect from a normal feed, recency, filtering, a way to flag something wrong, a way to pass something on. Every interactive piece here, the hero logic, the filter pill, the report button, the share flow, reuses a pattern already proven elsewhere in this series rather than inventing a new mechanism for each one, which is really the point of building this as a proper extensibility library in the first place: the investment in getting the underlying pattern right pays out again on the next layout that needs it.

Related Reading