19.2 Link Render Hook: Adding rel=noopener or External Link Icons
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.
The Basic Anatomy of a Link Hook
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. Oftennull.text: The inner text of the link.
Identifying External Links Like You Mean It
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
- Security First: Notice I used
noreferreras well asnoopener. It’s a belt-and-suspenders approach.noopeneris the critical one for security, preventing the new page from accessingwindow.opener.noreferreralso blocks theRefererheader. Just use both. - 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. Thetarget="_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. - Invalid URLs: The
try...catchis 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. - Performance: You’re running this function for every link. Keep it lean. Avoid expensive operations or deep object creation inside the hook. That
new URLcall 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.