SharePointVault ~/gotchas/spfx ⌕ Search 404 pages, error messages, scripts…Ctrl K ★ Star
M↓ README.mdM↓ spa-router-hijacks-anchor-clicks.md
sharepoint-vault›gotchas›spfx›spa-router-hijacks-anchor-clicks.mdhistoryedit on GitHub
● GOTCHAspfx· 3 min read

SharePoint's SPA router hijacks <a href> clicks — your onClick never fires

applies to: SharePoint Online (modern pages)reviewed: 2026-10-08#spfx#react#modern-pages#ux

Modern SharePoint's SPA router grabs <a href> clicks in the capture phase before your React onClick ever runs, so use a <button> or a role="link" element for in-app actions and keep anchors for real navigation.

SPA router moderního SharePointu zachytí kliknutí na <a href> v capture fázi dřív, než se vůbec spustí tvůj React onClick – pro akce uvnitř aplikace použij <button> nebo prvek s role="link" a <a href> nech jen pro skutečnou navigaci.

Symptom

A clickable card in an SPFx web part is built the classic way:

<a href={item.url} onClick={(e) => { e.preventDefault(); openModal(item); }}>…</a>

On a published modern page, clicking it navigates straight to the URL. The modal never opens, preventDefault() never runs — as if the handler didn't exist. Meanwhile an ordinary <button onClick> elsewhere in the same web part works fine.

Cause

Modern SharePoint runs its own client-side (SPA) router. It listens for clicks on internal <a href> links in the capture phase and performs its own navigation before the event ever reaches React's onClick (bubble phase). Your handler is simply too late.

Two things make this extra confusing:

  • The router only cares about <a href> with internal URLs — <button> and <div> are ignored, so some of your click handlers work.
  • In page edit mode the editor handles clicks differently, so the bug may not reproduce there. Always test on a published page.

Fix

Don't use <a href> for in-app actions. Use a non-anchor element:

// clickable card
<div
  role="link"
  tabIndex={0}
  onClick={() => openModal(item)}
  onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openModal(item); } }}
  style={{ cursor: 'pointer' }}
>…</div>

// or simply a button
<button type="button" onClick={() => openModal(item)}>Read more</button>

Reserve <a href> for real navigation — links that genuinely take the user away from the page (those you want the router or browser to handle).

The row-action variant: a button inside a link

The trap resurfaces when the anchor is legitimate navigation but you need a small action on top of it — the classic "list row links to the detail page, plus an edit pencil on the right":

// Broken: the router navigates before onEdit ever runs — and a <button>
// inside an <a> is invalid HTML in the first place.
<a href={item.url}>
  …row…
  <button onClick={onEdit}>✎</button>
</a>

Make the action a sibling of the anchor and position it over the row:

<div style={{ position: 'relative' }}>
  <a href={item.url}>…row…</a>
  <button
    type="button"
    onClick={onEdit}
    style={{ position: 'absolute', right: 0, top: 12 }}
  >✎</button>
</div>

The click now lands on the button, never on the anchor, so there is nothing for the router to hijack — and the markup is valid.

The injected-HTML variant: links inside content you render as HTML

Content that is generated outside your web part — a documentation package, a knowledge-base export, Markdown converted to HTML — often cross-links its own pages with relative links (<a href="chapter-06-site-lifecycle.html">). Rendered through dangerouslySetInnerHTML on a modern page, such a link resolves against the page URL (/sites/x/SitePages/chapter-06-….html) and ends in a 404. You can't fix it with a click handler either — it's an internal <a href>, so the router takes it first.

Rewrite the HTML before rendering instead of trying to catch the click:

// relative link to a page the package really has → button with the target id
html = html.replace(/<a\s+[^>]*?href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (whole, href, text) => {
  const id = targetIdFromHref(href);           // e.g. "chapter-06-….html" → "CH-06"; null for external links
  if (!id) return whole;                       // external link: leave it as published
  if (!knownIds.has(id)) return `<span>${text}</span>`; // dead target: plain text beats a 404
  return `<button type="button" data-target="${id}">${text}</button>`;
});
// one delegated listener on the container (the HTML isn't React, so per-button handlers aren't possible)
<div onClick={e => {
  const btn = (e.target as HTMLElement).closest('[data-target]');
  if (btn) openInApp(btn.getAttribute('data-target')!);
}} dangerouslySetInnerHTML={{ __html: html }} />

Only put a value into the attribute that you matched with a strict pattern (letters, digits, dashes), never the raw href. Do the same for any export of that content (print view, downloaded HTML): there the relative link leads nowhere, so keep just its text. And measure the whole package before you trust a sample — generators tend to repeat a pattern in every page (in one real case every section also started with an <h2> that duplicated the heading the reader already rendered).

Notes

  • Diagnostic signature: on a published page the click "just navigates", preventDefault has no effect, but the same handler on a <button> works → it's this.
  • Keep accessibility in mind when replacing anchors: role="link" (or a real <button>), tabIndex={0} and Enter/Space handling, as shown above.
Was this helpful?ask or add your experience on GitHub →
Saved you an hour? A star on GitHub helps other SharePoint people find the vault.★ Star on GitHub
last reviewed: 2026-10-08last updated: 2026-10-08historyedit on GitHubview source