An Astro plugin to open external links in a new tab (the accessible way)
When writing Markdown content, I wanted a simple rule: internal links to behave normally, external links to open in a new tab but also, to be accessible.
The “obvious” solution (and why it kinda sucks)
We all know that opening links in a new tab isn’t just:
target="_blank"
If you care about security and accessibility, you end up with something like this:
<a
href="https://www.keycloak.org/documentation"
target="_blank"
rel="noopener noreferrer"
aria-label="keycloak documentation"
>
keycloak docs
</a>
This is ok but… Man, it’s painfull to read and write… Imagine if you also want to add aria-describedby, that would make it even worse to read. I really wanted to keep my markdown as simple as possible.
Astro supports MDX, so why not I use it ?
Yes, Astro supports MDX, and you could create a helper component like:
<ExternalLink href="...">Docs</ExternalLink>
But I wanted:
- Plain Markdown, not MDX
- No custom syntax
- Accessibility handled automatically
- Works everywhere Markdown is used
So instead of changing how I write content, I decided to change how Markdown is processed with Astro.
The right layer: a rehype plugin
This post was inspired by Daniele Salvagni’s (opens in a new tab) article on opening external links in a new tab in Astro. I started from a similar idea, but wanted a solution that works in plain Markdown and puts accessibility front and center.
Astro lets you hook directly into the Markdown -> HTML pipeline using rehype plugins.
That’s the perfect place to do the magic. So I built a small plugin that does exactly that.
What does it do?
For every Markdown link:
- Leaves internal links alone
- Detects external links
- Automatically adds:
target="_blank"rel="noopener noreferrer"aria-describedbyfor screen readers
All while I keep writing normal Markdown
[Astro Docs](https://docs.astro.build)
The plugin
import type { RehypePlugin } from "@astrojs/markdown-remark";
import { visit } from "unist-util-visit";
import type { Element, Parent } from "hast";
const DESCRIBER_ID = "external-link-new-tab";
export const externalLinks: RehypePlugin = ({ domain = "" } = {}) => {
return (tree) => {
let hasInsertedNote = false;
visit(tree, "element", (node: Element, index, parent: Parent | null) => {
if (
node.tagName !== "a" ||
!node.properties?.href ||
node.properties.target ||
!parent ||
typeof index !== "number"
) {
return;
}
const href = node.properties.href.toString();
if (!href.startsWith("http")) return;
let url: URL;
try {
url = new URL(href);
} catch {
return;
}
const isExternal =
url.hostname !== domain &&
!url.hostname.endsWith(`.${domain}`);
if (!isExternal) return;
node.properties.target = "_blank";
node.properties.rel = "noopener noreferrer";
node.properties["aria-describedby"] = DESCRIBER_ID;
if (!hasInsertedNote) {
parent.children.splice(index + 1, 0, {
type: "element",
tagName: "span",
properties: {
id: DESCRIBER_ID,
className: ["sr-only"],
},
children: [
{
type: "text",
value: "(opens in a new tab)",
},
],
});
hasInsertedNote = true;
}
});
};
};
Adding it to Astro config:
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
import { externalLinks } from "./src/plugins/externalLinks";
export default defineConfig({
site: "https://projectbrackets.com/",
integrations: [sitemap()],
markdown: {
rehypePlugins: [
[externalLinks, { domain: "projectbrackets.com" }],
],
},
});
Happy coding!