Right, let’s talk about link hooks. You’ve probably been told a thousand times to add rel="noopener" to external links for security, or maybe you want to slap an external link icon on them for your users. Doing this manually in your Markdown is a soul-crushing exercise in missing one and then getting hacked by a three-letter agency. I’m kidding. Probably. The point is, you shouldn’t have to.

This is precisely why the link render hook exists. It’s your escape hatch from the default rendering, letting you surgically modify or completely replace the HTML output for every single link your Markdown parser finds. It’s one of those features that separates the pros from the… well, from people who manually edit links.

Here’s the skeleton. You provide a function to your Markdown parser (we’ll use Marked, as it’s common and has a great hooks implementation) that gets called every time a link (<a>) is about to be rendered. This function gets all the juicy details about the link and gives you a string of HTML to return. No return, no link.

// Using Marked as an example
import { marked } from 'marked';

// Define your hook
const linkHook = (href, title, text) => {
  // Your logic here
  let newHtml = // ... your custom <a> tag
  return newHtml;
};

// Tell Marked to use it
marked.use({ renderer: { link: linkHook } });

The three arguments are your entire world here:

  • href: The URL. This is your key to figuring out if the link is internal or external.
  • title: The title attribute from the Markdown, if one was provided. Often null.
  • text: The inner text of the link.

This is the first trap. A naive check might be if (!href.startsWith('/')), but that’s hopelessly myopic. What about ./relative paths? What about #anchor links? What about other protocols like mailto: or tel:? You need a more robust method.

The most reliable way is to try to create a URL object from the href. If it has a hostname (and that hostname isn’t your site’s), it’s external. This also beautifully handles all the relative path nonsense for you.

const linkHook = (href, title, text) => {
  let attributes = '';

  try {
    // Base URL should be your site's domain for accurate resolution
    const url = new URL(href, 'https://my-awesome-site.com');
    const isExternal = url.hostname !== 'my-awesome-site.com';

    if (isExternal) {
      attributes += ' rel="noopener noreferrer" target="_blank"';
      // We'll get to the icon in a second
    }
  } catch (e) {
    // This usually means the URL is invalid. Let it be, the browser will handle it poorly anyway.
    console.warn(`Invalid URL in link hook: ${href}`);
  }

  // Build the vanilla link, then inject our new attributes
  const defaultLink = marked.Renderer.prototype.link.call(this, href, title, text);
  // This is a bit of a cheat: we inject our attributes before the closing '>'
  return defaultLink.replace('>', `${attributes}>`);
};

See what we did there? We used the default renderer to build the base link, then we modified it. This is often safer than building the entire <a> tag from scratch, as it preserves any other internal logic Marked might have.

Adding an Icon Without Losing Your Mind

Now for the icon. You don’t want to just slap text like “(external)” in there—that’s what web design looked like in 1998. You want an SVG icon. But you can’t just return a string with an icon next to the link; you need to append it inside the link, after the text.

This is where you ditch the “modify the default” approach and build it yourself for full control.

const linkHook = (href, title, text) => {
  let relAttribute = '';
  let iconHtml = '';

  try {
    const url = new URL(href, 'https://my-awesome-site.com');
    if (url.hostname !== 'my-awesome-site.com') {
      relAttribute = ' rel="noopener noreferrer" target="_blank"';
      // A simple, inline SVG for an arrow. You can style this with CSS later.
      iconHtml = ' <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>';
    }
  } catch (e) {}

  // Build it from the ground up
  return `
    <a href="${href}"${title ? ` title="${title}"` : ''}${relAttribute}>
      ${text}${iconHtml}
    </a>
  `.trim();
};

The Devil’s in the Details: Pitfalls and Edge Cases

  1. Security First: Notice I used noreferrer as well as noopener. It’s a belt-and-suspenders approach. noopener is the critical one for security, preventing the new page from accessing window.opener. noreferrer also blocks the Referer header. Just use both.
  2. Accessibility: That SVG has aria-hidden="true". This is crucial. Screen readers don’t need to announce “external link icon” on every single external link—it’s visual decoration. The target="_blank" is already a major accessibility concern on its own as it can disorient users. An icon is a visual cue for that action, not a replacement for proper semantic information.
  3. Invalid URLs: The try...catch is essential. Markdown might contain garbage like [link](javascript:alert('no')) or just plain broken text. Your hook shouldn’t crash the entire render process because of one bad link. Fail gracefully.
  4. Performance: You’re running this function for every link. Keep it lean. Avoid expensive operations or deep object creation inside the hook. That new URL call is about as expensive as it should get.

This hook transforms your Markdown from a simple text format into a powerful, automatically-secured content pipeline. It’s the kind of thing that makes you look like a wizard, and rightly so. Now go implement it and stop worrying about your links.