X and LinkedIn Share Links Without Third-Party Scripts

This guide is part of Building a Custom Share Sheet, which itself is part of Permission Flows and Progressive Enhancement.

Vendor share buttons are one of the worst value-for-weight trades on the web: a third-party script, an iframe per button, a set of cookies, and a measurable hit to page speed — to produce a link you can write in one line. Every mainstream network accepts a plain HTTPS URL with query parameters, and the rich preview card everyone assumes the SDK generates is actually produced by the destination reading your page’s Open Graph tags.

Feature Detection Gate

Social links are the fallback tier, so check whether the native sheet should take precedence.

// share-tier.js
export function shareTier(payload) {
  if (window.isSecureContext && typeof navigator.share === 'function') {
    if (!payload?.files?.length) return 'native';
    if (typeof navigator.canShare === 'function' && navigator.canShare(payload)) return 'native';
  }
  return 'links';           // plain anchors, no SDK, works everywhere
}

Because the links tier requires nothing at all — no API, no permission, no secure context — it is the one route that is always available, which is exactly why it deserves to be plain markup rather than a script dependency.

Step 1 — Build the Intent URLs

// social-intents.js
export function socialShareUrls({ title = '', text = '', url, via }) {
  const u = encodeURIComponent(url);
  const t = encodeURIComponent(title);

  return {
    x: `https://x.com/intent/tweet?url=${u}&text=${t}` + (via ? `&via=${encodeURIComponent(via)}` : ''),
    linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${u}`,
    facebook: `https://www.facebook.com/sharer/sharer.php?u=${u}`,
    reddit: `https://www.reddit.com/submit?url=${u}&title=${t}`,
    mastodon: `https://mastodonshare.com/?url=${u}&text=${t}`,
    bluesky: `https://bsky.app/intent/compose?text=${encodeURIComponent(`${title} ${url}`.trim())}`
  };
}

Two asymmetries are worth internalising. LinkedIn and Facebook accept only the URL — everything the post displays comes from your page’s metadata, so passing a title achieves nothing. X and Bluesky accept text, and Bluesky (like WhatsApp) takes a single composed field rather than separate parts.

Which Destination Accepts What A table of five destinations against three parameters. LinkedIn and Facebook accept only a URL and take everything else from page metadata. X and Reddit accept a URL plus a title or text. Bluesky accepts a single composed text field containing both. Destination url text / title preview comes from X yes yes (separate) twitter:card meta tags LinkedIn yes ignored og:title / og:image Facebook yes ignored og:title / og:image Reddit yes yes (title) og tags on fetch Bluesky in text one composed field og tags on fetch

Step 2 — Let Metadata Carry the Preview

The card the recipient sees is produced by the destination fetching your URL and reading its meta tags. This is the part that actually determines whether a share looks good.

<meta property="og:type" content="article">
<meta property="og:title" content="Building a Custom Share Sheet">
<meta property="og:description" content="Ship your own share dialog when navigator.share is unavailable.">
<meta property="og:url" content="https://www.example.com/guides/custom-share-sheet/">
<meta property="og:image" content="https://www.example.com/og/custom-share-sheet.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">

Absolute URLs are mandatory — a relative og:image is silently ignored by every crawler. The 1200×630 dimensions are what the large-card layouts expect; smaller images are downgraded to a small thumbnail without warning. And because these crawlers do not execute JavaScript, metadata injected at runtime by a single-page application is invisible to them: it has to be in the server-rendered HTML.

Step 3 — Render Real Anchors

Server-rendered links work without JavaScript, cost nothing at runtime, and cannot be blocked by a popup blocker because the navigation is the anchor’s own.

// render-social-links.js
import { socialShareUrls } from './social-intents.js';

const DESTINATIONS = [
  { key: 'x', label: 'Share on X' },
  { key: 'linkedin', label: 'Share on LinkedIn' },
  { key: 'reddit', label: 'Share on Reddit' }
];

export function renderSocialLinks(list, payload) {
  const urls = socialShareUrls(payload);

  list.replaceChildren(...DESTINATIONS.map(({ key, label }) => {
    const item = document.createElement('li');
    const anchor = document.createElement('a');
    anchor.href = urls[key];
    anchor.textContent = label;              // full label — not just an icon
    anchor.target = '_blank';
    anchor.rel = 'noopener noreferrer';
    item.append(anchor);
    return item;
  }));
}

Give each link real text rather than an unlabelled icon. An <a> whose only content is an SVG has no accessible name, which is one of the most common failures an automated audit reports on share widgets. If the design calls for icon-only buttons, keep the text in a visually-hidden span.

Step 4 — Measure Clicks Yourself

Public share counts no longer exist on the major networks, and outbound clicks are the honest metric anyway.

// track-outbound.js
export function trackOutbound(list, report) {
  list.addEventListener('click', (event) => {
    const anchor = event.target.closest('a[href]');
    if (!anchor) return;
    const destination = new URL(anchor.href).hostname.replace(/^www\./, '');
    // Fire-and-forget so the navigation is never delayed or blocked.
    report({ event: 'share_click', destination });
  });
}

sendBeacon inside report is the right transport, because the page is about to be backgrounded by the new tab. And note what is recorded: the destination hostname only. The shared URL and title are the user’s content, and the same reasoning applies here as in logging share outcomes without leaking payloads.

What the SDK Costs and What It Adds Two columns. Vendor share widgets add third-party scripts, iframes, cookies and layout shift, and deliver a preview card and a share count they do not actually control. Plain anchors add nothing at runtime and deliver the identical preview, which comes from the page's own metadata. Vendor SDK buttons − third-party script per network − an iframe per button − cookies set on your readers − layout shift as buttons hydrate + a preview it does not generate Plain anchors + zero bytes of JavaScript + works with JS disabled + no cookies, no third party + no layout shift — rendered server-side + the identical preview card

The last of the SDK’s costs to remove is the runtime itself. Because every intent URL is a pure function of the page’s own canonical URL and title, the markup can be produced at build or request time and shipped complete.

// server-render.js — runs at build time, emits static HTML
import { socialShareUrls } from './social-intents.js';

export function shareListHtml(page) {
  const urls = socialShareUrls({ title: page.title, url: page.canonicalUrl });

  return `<ul class="share-targets">
  <li><a href="${escapeAttr(urls.x)}" target="_blank" rel="noopener noreferrer">Share on X</a></li>
  <li><a href="${escapeAttr(urls.linkedin)}" target="_blank" rel="noopener noreferrer">Share on LinkedIn</a></li>
  <li><a href="${escapeAttr(urls.reddit)}" target="_blank" rel="noopener noreferrer">Share on Reddit</a></li>
</ul>`;
}

function escapeAttr(value) {
  return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
}

Three properties follow from this, and together they are most of the reason to abandon the widgets. The links work with JavaScript disabled or still loading, because they are ordinary anchors present in the initial HTML. They cause no layout shift, because nothing hydrates into place after paint. And they cost nothing at runtime — no script to parse, no connection to open, no cookie to set.

Note the attribute escaping. The intent URLs already contain percent-encoded values from encodeURIComponent, but the query separators between them are literal ampersands, and an unescaped & inside an HTML attribute is a validity error that some parsers resolve unpredictably. Escaping at the markup boundary is a different job from encoding at the URL boundary, and both are required.

Failure Modes and Recovery

Symptom Cause Minimal fix
Preview card has no image og:image relative or undersized Absolute URL at 1200×630
LinkedIn ignores the title passed in the URL It only reads url Fix og:title on the shared page
Card empty for a single-page app Crawlers do not run JavaScript Render meta tags server-side
Link opens a blank tab Popup blocked after an await Let the anchor navigate; do not use window.open
Audit reports a link with no name Icon-only anchor Add text, or a visually-hidden label
Destination can control your tab target="_blank" without rel Add rel="noopener noreferrer"
Share counts always zero Public count endpoints were removed Track outbound clicks yourself
Two Different Escapes, Both Required Percent-encoding protects each value inside the query string. HTML attribute escaping protects the resulting URL when it is written into an href. They operate at different layers and neither substitutes for the other. raw values title: Ops & Safety url: https://x.test/a?b=1 both contain delimiters encodeURIComponent & → %26 ? → %3F protects the query string applied per value attribute escaping & → &amp; " → &quot; protects the href markup applied to the whole URL

Browser and Platform Caveat

Intent endpoints are ordinary HTTPS URLs, so they behave identically across Chrome, Safari, Firefox and Edge, and on mobile they are intercepted by the installed app through universal links where one is present. The genuine variability is on the destination side rather than the browser side: these endpoints are product surfaces, not standards, and their parameter names change without notice — x.com/intent/tweet still honours the older twitter.com form today, but that is a courtesy rather than a guarantee. Keep the URL construction in one small module so a change is a one-line fix, verify the rendered card with each network’s own preview debugger after any metadata change, and always keep copy-link in the same list so the sheet still works when a destination breaks.

FAQ

Do I lose the rich preview card by dropping the SDK?

No. The preview is generated by the destination when it fetches your URL and reads its Open Graph and Twitter card meta tags. The SDK never produced it. Correct metadata on the shared page is the only thing that matters.

How do share counts work without the vendor script?

They do not — the major networks removed public share-count endpoints years ago, so widgets that still display a number are usually showing a value from your own analytics. Count outbound clicks yourself if you need the metric.

Is it safe to open these links in a new tab?

Yes, provided you add rel="noopener noreferrer" alongside target="_blank". Without it the destination receives a reference to your window through window.opener and can navigate it, and your URL leaks as a referrer.