Opening External Post Links in a New Tab in Astro

How to create and configure a Sätteri plugin to automatically open external links from Markdown posts in a new tab.

#Astro#Markdown#Sätteri#JavaScript

Introduction

When building a website or writing articles, it is common to use external links for additional information, references, code repositories, or other content.

When a reader follows one of these links, we usually want the main content to remain open. That is why it is common to open these links in a new browser tab or window.

I was surprised to discover that Astro does not open these links in a new tab by default. The links continue to open in the same tab, whether we use Markdown or the HTML anchor tag (<a href="...">) directly.

The problem is that Markdown does not have its own syntax for defining HTML attributes such as target="_blank". The traditional syntax is still something like:

[Astro Doc](https://docs.astro.build/)

To handle this automatically, we can use a plugin that modifies the links generated by Markdown before they are turned into HTML.

In this article, we will create this plugin using Sätteri, the Markdown processor we are using in the project for this type of extension.

What We Want to Do

The idea is simple. Whenever a link’s href starts with http:// or https://, we want the HTML generated by Astro to look like this:

<a href="https://docs.astro.build/" target="_blank" rel="noopener noreferrer">
  Astro Doc
</a>

We do not want to add attributes manually to every link. The plugin will handle this change automatically.

Internal links to the site itself, on the other hand, should not be changed.

Installing Sätteri

Astro supports the Sätteri processor through the @astrojs/markdown-satteri package. To create our plugin, we will also use the satteri package.

Run:

npm install @astrojs/markdown-satteri satteri

@astrojs/markdown-satteri lets us use Sätteri as the Markdown processor in Astro, while the satteri package provides the APIs used to create custom plugins.

Astro’s documentation also presents Sätteri as an option for creating plugins that modify HTML elements generated from Markdown.

Creating the Plugin

Now let’s create a file for our plugin.

In my project, I organized plugins under src/plugins. So, create:

src/plugins/external-links.ts

The file contents will be:

import { defineHastPlugin } from 'satteri';

export const externalLinks = defineHastPlugin({
	name: 'external-links',
	element: {
		filter: ['a'],
		visit(node, ctx) {
			const href = node.properties?.href;

			if (typeof href === 'string' && /^https?:\/\//.test(href)) {
				ctx.setProperty(node, 'target', '_blank');
				ctx.setProperty(node, 'rel', ['noopener', 'noreferrer']);
			}
		},
	},
});

Let’s take a look at what is happening.

The plugin uses defineHastPlugin to define a HAST plugin.

HAST is a tree representation of HTML. This means that, at this stage of processing, we can work with the HTML elements that will be generated from Markdown.

In our case, we are only interested in <a> elements:

element: {
	filter: ['a'],

visit will be executed for these elements.

Inside visit, we first retrieve the link address:

const href = node.properties?.href;

We then check whether it starts with http:// or https://:

if (typeof href === 'string' && /^https?:\/\//.test(href)) {

This check is important because we only want to modify external links.

For example:

https://docs.astro.build/
https://github.com/
http://example.com/

Internal links such as:

/blog/

do not pass this condition and retain the site’s normal behavior.

Adding the target

When we find an external link, we add:

ctx.setProperty(node, 'target', '_blank');

This makes the resulting HTML use:

target="_blank"

This causes the browser to open the address in a new tab.

Adding rel

We also add:

ctx.setProperty(node, 'rel', ['noopener', 'noreferrer']);

The result will be equivalent to:

rel="noopener noreferrer"

In addition to being a recommended setting for links that use target="_blank", noopener prevents the opened page from accessing the window that initiated the navigation. noreferrer also prevents the referrer information (Referer) from being sent to the destination page.

This makes opening in a separate tab the default behavior for links with http:// or https:// URLs found in Markdown content.

If you want to add other attributes by default, simply follow the same approach and include them here.

Configuring Astro

Now we need to tell Astro that we want to use our plugin while processing Markdown files.

Open the following file, which is located at the project root:

astro.config.mjs

First, import satteri and the plugin we just created:

import { satteri } from '@astrojs/markdown-satteri';
import { externalLinks } from './src/plugins/external-links';

Then configure the processor inside markdown:

markdown: {
	processor: satteri({
		hastPlugins: [externalLinks],
	}),
},

In my project, the configuration looks like this:

// @ts-check

import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import { defineConfig } from 'astro/config';
import { satteri } from '@astrojs/markdown-satteri';
import { externalLinks } from './src/plugins/external-links';

export default defineConfig({
	site: 'https://dougcosta.com',
	integrations: [mdx(), sitemap()],
	markdown: {
		processor: satteri({
			hastPlugins: [externalLinks],
		}),
	},
});

Testing the Plugin

Now we can test the behavior.

Add an external link to a Markdown post:

[Astro Doc](https://docs.astro.build/)

Then run the project locally:

npm run dev

Open the post in your browser and click the link. It should open in a new tab or window, depending on the browser and its settings.

We can also inspect the HTML generated by the browser. Right-click the page and choose the option corresponding to Inspect (the wording may vary depending on the browser).

The link should contain:

target="_blank"

and:

rel="noopener noreferrer"

An Important Detail: Be Careful with draft: true

During this process, I came across a detail that can easily lead to the wrong conclusion while testing.

In my project, posts marked as:

draft: true

are not included in the production build.

This means that if you test the plugin by running the command below while the post is marked as draft: true, the post’s HTML file will not be generated:

npm run build

In that case, looking for target="_blank" in the dist directory will not allow you to verify that the plugin is working, because the post itself will not be present in the build. Therefore, there will be no HTML file for that post to inspect.

Therefore, before testing the plugin in a production build, make sure the post you are using for the test is not marked as draft: true.

For a post that should be included in the build, the frontmatter should look like this, for example:

---
title: "My post"
description: "Post description."
pubDate: 2026-09-16
draft: false
---

This detail may seem minor, but it was precisely what initially made validating the plugin in my project more difficult.

Why Use a Plugin for This?

An alternative would be to control target="_blank" individually in each component or manually modify the links after the Markdown had been rendered.

However, that would create an additional responsibility for every new post.

With the plugin, the rule is centralized:

Post Markdown
     ↓
Sätteri
     ↓
Plugin external-links
     ↓
Identifica links externos
     ↓
Adiciona target="_blank"
     ↓
HTML final

From that point on, any new external link added to a post goes through the same processing automatically.

This also means we do not need to remember to add specific attributes every time we create a link to documentation or another website.

Conclusion

In my view, it is surprising that Markdown does not have its own syntax for controlling HTML attributes such as target. This type of behavior is quite common when building a website, so it would be useful to have a more direct way to configure it. The solution I found was to use a plugin, as described in this article, but it would also be possible to control this behavior individually in each component or use another approach.

The solution itself turned out to be relatively simpland: use Sätteri to intercept the <a> elements generated by Markdown and add the required attributes to the links. Even so, it took some additional research to arrive at this approach.

The important takeaway was realizing that this type of behavior can be centralized in a plugin. Instead of adapting each post individually, we create a rule that applies to all Markdown content on the site.

Another important lesson concerns testing. When working with posts that have draft: true, we need to remember that they are not included in the production build. Otherwise, we may interpret the absence of generated content as a problem with the plugin, when the post simply was not included in the build.

If you do not have a website yet and want to build one with Astro, also check out this article:

In it, I walk through the process of creating an Astro project from the blog template and configuring automatic deployment to GitHub Pages using GitHub Actions.

References

The documentation used as references for this article is: