Skip to main content
When rendering AI-generated or user-generated content, links can direct users to unexpected or malicious sites. Streamdown’s link safety feature intercepts every link click and shows a confirmation modal before navigating away — similar to how ChatGPT handles external links.

Default behavior

Link safety is enabled by default. The default configuration is:
const defaultLinkSafetyConfig: LinkSafetyConfig = {
  enabled: true,
};
When a user clicks any link, a modal appears showing the full URL with options to open it or dismiss.

LinkSafetyConfig

interface LinkSafetyConfig {
  enabled: boolean;
  onLinkCheck?: (url: string) => Promise<boolean> | boolean;
  renderModal?: (props: LinkSafetyModalProps) => React.ReactNode;
}
enabled
boolean
required
Enable or disable link interception. Set to false to allow all links to open directly.
Optional callback invoked before showing the modal. Return true to open the link directly without a modal (safelist). Return false to show the confirmation modal. Supports async validation.
renderModal
(props: LinkSafetyModalProps) => React.ReactNode
Optional function to replace the built-in modal with a custom component.
import { Streamdown } from "streamdown";

export default function Chat({ content }) {
  return (
    <Streamdown linkSafety={{ enabled: false }}>
      {content}
    </Streamdown>
  );
}

Safelisting trusted domains

Use onLinkCheck to allow links from trusted domains to open without a modal:
<Streamdown
  linkSafety={{
    enabled: true,
    onLinkCheck: (url) => {
      return (
        url.startsWith("https://your-app.com") ||
        url.startsWith("https://github.com")
      );
    },
  }}
>
  {content}
</Streamdown>
The callback receives the full URL string. Return values:
  • true — open the link immediately, skip the modal
  • false — show the confirmation modal
  • Promise<boolean> — async checks are supported for server-side validation

Async safelist check

<Streamdown
  linkSafety={{
    enabled: true,
    onLinkCheck: async (url) => {
      const res = await fetch("/api/check-url", {
        method: "POST",
        body: JSON.stringify({ url }),
      });
      const { isSafe } = await res.json();
      return isSafe;
    },
  }}
>
  {content}
</Streamdown>

Custom modal

Replace the built-in modal with your own component using renderModal. The function receives a LinkSafetyModalProps object:
interface LinkSafetyModalProps {
  isOpen: boolean;
  onClose: () => void;
  onConfirm: () => void;
  url: string;
}
PropTypeDescription
isOpenbooleanWhether the modal should be visible
onClose() => voidCall to dismiss the modal without navigating
onConfirm() => voidCall to open the link and close the modal
urlstringThe URL the user clicked
import { Streamdown, type LinkSafetyModalProps } from "streamdown";

function MyLinkModal({ url, isOpen, onClose, onConfirm }: LinkSafetyModalProps) {
  if (!isOpen) return null;

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal" onClick={(e) => e.stopPropagation()}>
        <h2>External link</h2>
        <p>You are about to visit:</p>
        <code>{url}</code>
        <div className="actions">
          <button type="button" onClick={onClose}>Cancel</button>
          <button type="button" onClick={onConfirm}>Continue</button>
        </div>
      </div>
    </div>
  );
}

export default function Chat({ content }) {
  return (
    <Streamdown
      linkSafety={{
        enabled: true,
        renderModal: (props) => <MyLinkModal {...props} />,
      }}
    >
      {content}
    </Streamdown>
  );
}

TypeScript types

import type { LinkSafetyConfig, LinkSafetyModalProps } from "streamdown";

const config: LinkSafetyConfig = {
  enabled: true,
  onLinkCheck: (url) => url.startsWith("https://safe.example.com"),
  renderModal: (props: LinkSafetyModalProps) => <MyModal {...props} />,
};

Combining with security hardening

Link safety and content hardening address different attack vectors and work together:
import { Streamdown, defaultRehypePlugins } from "streamdown";
import { harden } from "rehype-harden";

export default function SecureChat({ content }) {
  return (
    <Streamdown
      linkSafety={{
        enabled: true,
        onLinkCheck: (url) => url.startsWith("https://trusted.example.com"),
      }}
      rehypePlugins={[
        defaultRehypePlugins.raw,
        defaultRehypePlugins.sanitize,
        [
          harden,
          {
            allowedLinkPrefixes: ["https://trusted.example.com"],
            allowedProtocols: ["https", "mailto"],
          },
        ],
      ]}
    >
      {content}
    </Streamdown>
  );
}
This gives two layers of protection:
  1. rehype-harden rewrites or blocks disallowed URLs at render time — malicious links never reach the DOM
  2. Link safety modal requires user confirmation before any navigation — even for links that passed the hardening check
See Security for more on content hardening.

Build docs developers (and LLMs) love