React Email11 min read

React Email link checker: catch broken and placeholder links in CI

Render every React Email template with fixtures, extract each href and image src, and fail CI on placeholders, anchors, undefined values, and dead pages.

R

React Emails Pro

September 27, 2026

A shipping notification can pass the type check, look right in the preview server, and still reach a customer with this button:

received-email.html
<a href="https://app.acme.com/orders/undefined/tracking">Track your order</a>

Nothing in the component is wrong. The URL builder received an order ID that had not been loaded yet, turned it into a string, and the template rendered whatever it was given. The same class of mistake puts localhost:3000 in a password reset email, an example.com help link copied from a starter template, or a button with no href at all.

On September 3, 2026, Resend added a Link Checker to its Broadcast and Template editors. It flags broken links, missing URLs, placeholder destinations, and in-page anchors before you send. If your templates live in a React Email codebase and go out through the API, that editor never sees them. This guide builds the same safety net inside your repository: static rules on every pull request, network checks on a schedule, and a cheap guard in the send path.


What a link check should catch

Split the problems by what it takes to detect them. Most need nothing more than the rendered HTML. A few need an HTTP request, and those are slower, flakier, and occasionally unsafe to make.

ProblemExampleCaught by
Missing URLA Button whose href prop was undefinedStatic rule
In-page anchor#pricingStatic rule
Unfilled value/orders/undefined/tracking, {{reset_url}}Static rule
Relative path/accountStatic rule
Development or placeholder hostlocalhost:3000, example.comStatic rule
Plain HTTPhttp://acme.com/helpStatic rule
Unexpected hostAn old domain left in a shared footerStatic warning
Dead pageA 404 after a route was renamedNetwork check
Redirect chainMore than five hops before a page loadsNetwork check

Link problems grouped by the check that finds them.

Relative paths deserve a word. In a web app, /account resolves against the page you are on. An email has no such page. The message is displayed inside Gmail, Outlook, or Apple Mail, none of which know your domain, so a relative link cannot reach your app.

Anchors are similar. Resend's own checker flags them because they don't work in most email clients. A "Back to top" link that works in the browser preview usually does nothing in the inbox.


Checking your .tsx source does not work. The source says href={resetUrl}, which tells you nothing about the URL. Links arrive as props, shared footers add their own, and conditional branches decide which buttons exist. The HTML that render produces is what recipients get, so that is what to inspect.

Use an HTML parser rather than a regular expression. React escapes & in attributes as &amp;, and a parser decodes it back, so query strings are compared as real URLs. This version uses node-html-parser, which is small and has no native dependencies:

lib/email-links.ts
import { parse } from "node-html-parser";

export interface EmailLink {
  kind: "link" | "image";
  url: string;
  // Visible text or alt text, so reports point at the right element.
  label: string;
}

export function extractLinks(html: string): EmailLink[] {
  const root = parse(html);

  const links = root.querySelectorAll("a").map((a): EmailLink => ({
    kind: "link",
    url: (a.getAttribute("href") ?? "").trim(),
    label: a.textContent.replace(/\s+/g, " ").trim().slice(0, 60),
  }));

  const images = root.querySelectorAll("img").map((img): EmailLink => ({
    kind: "image",
    url: (img.getAttribute("src") ?? "").trim(),
    label: img.getAttribute("alt") ?? "",
  }));

  return [...links, ...images];
}

Images are included on purpose. A logo served from a preview server or a deleted bucket is the first broken thing a reader sees, and it fails the same way a link does. The labelfield matters more than it looks: "ERROR: Reset password" is quicker to act on than a bare URL in a report with forty lines.


Static rules that need no network

These rules run in milliseconds, give the same answer every time, and catch most real mistakes. That makes them safe to run on every pull request and inside the send path.

lib/link-rules.ts
import type { EmailLink } from "./email-links";

export type Severity = "error" | "warning";

export interface LinkProblem {
  severity: Severity;
  reason: string;
  link: EmailLink;
}

export interface LinkRuleOptions {
  // Hosts your emails are expected to link to or load images from.
  allowedHosts: string[];
}

const DEV_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]"]);
const RESERVED_SUFFIXES = [".local", ".test", ".internal"];
const EXAMPLE_DOMAINS = ["example.com", "example.org", "example.net"];
const UNFILLED_VALUE = /^(undefined|null|NaN|\[object Object\])$/;

export function findLinkProblems(
  link: EmailLink,
  { allowedHosts }: LinkRuleOptions,
): LinkProblem[] {
  const fail = (severity: Severity, reason: string): LinkProblem[] => [
    { severity, reason, link },
  ];
  const raw = link.url;

  if (raw === "") return fail("error", "Missing URL");
  if (raw.startsWith("#")) {
    return fail("error", "In-page anchor; most email clients ignore these");
  }
  if (raw.includes("{{")) return fail("error", "Unfilled template variable");
  if (/^(mailto|tel):/i.test(raw)) {
    return /^(mailto|tel):\S+/i.test(raw) ? [] : fail("error", "Empty mailto or tel link");
  }

  let url: URL;
  try {
    url = new URL(raw);
  } catch {
    return fail("error", "Relative or malformed URL; an inbox has no base URL");
  }

  if (url.protocol !== "https:") {
    return fail("error", `Uses ${url.protocol} instead of https:`);
  }
  if (isPlaceholderHost(url.hostname)) {
    return fail("error", `Placeholder or development host: ${url.hostname}`);
  }

  const problems: LinkProblem[] = [];
  if (hasUnfilledValue(url)) {
    problems.push(...fail("error", "Contains an unfilled value such as undefined"));
  }
  if (!allowedHosts.includes(url.hostname)) {
    problems.push(...fail("warning", `Host not in allowlist: ${url.hostname}`));
  }
  return problems;
}

// URL parsing lowercases hostnames, so plain string checks are enough.
function isPlaceholderHost(host: string): boolean {
  return (
    DEV_HOSTS.has(host) ||
    RESERVED_SUFFIXES.some((suffix) => host.endsWith(suffix)) ||
    EXAMPLE_DOMAINS.some((domain) => host === domain || host.endsWith(`.${domain}`))
  );
}

function hasUnfilledValue(url: URL): boolean {
  const values = [
    ...url.pathname.split("/").map(safeDecode),
    ...url.searchParams.values(),
  ];
  return values.some((value) => UNFILLED_VALUE.test(value) || value.includes("{{"));
}

function safeDecode(value: string): string {
  try {
    return decodeURIComponent(value);
  } catch {
    return value;
  }
}

Decisions worth copying

  • Unfilled values are matched per segment, not as substrings. A path like /docs/nullable-fields is fine. A segment that is exactly undefined or a query value of null is not. The URL parser encodes { in paths, which is why segments are decoded before the comparison.
  • An unknown host is a warning, not an error. Order emails link to carrier tracking pages, app emails link to app store listings, and those hosts are legitimate. The warning exists to catch a domain you retired, not to forbid third parties. Promote it to an error if your emails should only ever point at your own domains.
  • Development hosts include reserved names. .test, .local, and the example.* domains never belong in a sent message. If your team uses a staging domain, you can add it here for production sends, but not for the CI run that checks staging.

Template packs, starter kits, and examples copied from docs use placeholder destinations until someone wires them to real routes. The placeholder rule is the check that tells you which ones you missed, including the footer links nobody looks at twice.


Render every template with fixtures that use your URL builder

The check is only as honest as the props you feed it. If a fixture passes a hand-typed https://app.acme.com/reset?token=abc, the checker proves the fixture is fine. It says nothing about the code that builds reset URLs in production, which is where the undefined came from.

Build fixture URLs with the same helper your application uses. Point it at staging through an environment variable:

lib/app-url.ts
export function appUrl(path: string, params: Record<string, string> = {}): string {
  const base = process.env.APP_URL;
  if (!base) throw new Error("APP_URL is not set");

  const url = new URL(path, base);
  for (const [key, value] of Object.entries(params)) {
    url.searchParams.set(key, value);
  }
  return url.toString();
}
emails/fixtures.tsx
import * as React from "react";
import { appUrl } from "../lib/app-url";
import PasswordReset from "./password-reset";
import OrderShipped from "./order-shipped";

// Build URLs with the same helper the app uses when it sends for real.
export const emailFixtures: Record<string, () => React.ReactElement> = {
  "password-reset": () => (
    <PasswordReset
      name="Test User"
      resetUrl={appUrl("/reset-password", { token: "fixture-token" })}
      logoUrl="https://cdn.acme.com/email/logo.png"
      helpUrl={appUrl("/help")}
    />
  ),
  "order-shipped": () => (
    <OrderShipped
      orderNumber="TEST-1042"
      trackingUrl={appUrl("/orders/TEST-1042/tracking")}
      orderUrl={appUrl("/orders/TEST-1042", { utm_source: "email" })}
    />
  ),
};

Keep these separate from PreviewProps if your previews use example.com URLs. Previews are for looking at a layout; these fixtures are for checking what the send path produces. Mixing them means either the preview needs a staging URL or the checker needs an exception for placeholders, and neither is an improvement.

One fixture per branch

A link inside a conditional branch is only checked if some fixture renders that branch. If the trial version of an email shows "Choose a plan" and the paid version shows "Manage billing," add a fixture for each. The same applies to locales when translated emails link to translated help pages. Name fixtures after the branch (trial-ending/paid-annual) so a failure in the report tells you which variant broke.


The runner script

The runner renders each fixture, applies the static rules, and optionally makes HTTP requests with --network. It exits with code 1 when any error is found, which is all CI needs. Run it with npx tsx scripts/check-email-links.tsx.

scripts/check-email-links.tsx
import { render } from "react-email";
import { emailFixtures } from "../emails/fixtures";
import { checkUrl, type UrlCheck } from "../lib/check-url";
import { extractLinks, type EmailLink } from "../lib/email-links";
import { findLinkProblems, type LinkProblem } from "../lib/link-rules";

type Finding = LinkProblem & { template: string };
type Source = { template: string; link: EmailLink };

const appHost = new URL(process.env.APP_URL ?? "https://app.acme.com").hostname;
const allowedHosts = [appHost, "acme.com", "cdn.acme.com"];
const runNetwork = process.argv.includes("--network");

async function main(): Promise<void> {
  const findings: Finding[] = [];
  const requestable = new Map<string, Source>();

  for (const [template, build] of Object.entries(emailFixtures)) {
    const html = await render(build());
    for (const link of extractLinks(html)) {
      const problems = findLinkProblems(link, { allowedHosts });
      findings.push(...problems.map((p) => ({ ...p, template })));
      if (problems.length === 0 && isSafeToRequest(link.url)) {
        requestable.set(link.url, { template, link });
      }
    }
  }

  if (runNetwork) {
    const checks = await mapWithLimit([...requestable], 4, async ([url, source]) => ({
      source,
      result: await checkUrl(url),
    }));
    for (const { source, result } of checks) {
      if (!result.ok) findings.push(toFinding(source, result));
    }
  }

  for (const f of findings) {
    console.log(`${f.severity.toUpperCase()} [${f.template}] ${f.reason}`);
    console.log(`  ${f.link.kind} "${f.link.label}" -> ${f.link.url || "(empty)"}`);
  }

  const errors = findings.filter((f) => f.severity === "error").length;
  console.log(`\n${errors} errors, ${findings.length - errors} warnings`);
  if (errors > 0) process.exitCode = 1;
}

// A request can have side effects: skip unsubscribe routes and one-time tokens.
function isSafeToRequest(raw: string): boolean {
  if (!raw.startsWith("https://")) return false;
  const url = new URL(raw);
  return !/unsubscribe/i.test(url.pathname) && !url.searchParams.has("token");
}

function toFinding(source: Source, result: Extract<UrlCheck, { ok: false }>): Finding {
  // Bot protection often answers automated requests with 401, 403, or 429.
  const blocked = result.status === 401 || result.status === 403 || result.status === 429;
  return {
    ...source,
    severity: blocked ? "warning" : "error",
    reason: blocked ? `${result.reason} (check manually)` : result.reason,
  };
}

async function mapWithLimit<T, R>(
  items: T[],
  limit: number,
  fn: (item: T) => Promise<R>,
): Promise<R[]> {
  const results: R[] = [];
  const queue = items.entries();
  // Workers share one iterator, so each item is taken exactly once.
  const worker = async () => {
    for (const [index, item] of queue) results[index] = await fn(item);
  };
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}

main().catch((error: unknown) => {
  console.error(error);
  process.exitCode = 1;
});

Here is the output against two templates with deliberate mistakes: a "Back to top" anchor, a tracking URL built from a missing order ID, a leftover example.com returns link, a relative account link, and an HTTP help link.

terminal
ERROR [password-reset] In-page anchor; most email clients ignore these
  link "Back to top" -> #top
ERROR [order-shipped] Contains an unfilled value such as undefined
  link "Track your order" -> https://staging.acme.com/orders/undefined/tracking
ERROR [order-shipped] Placeholder or development host: example.com
  link "Returns policy" -> https://example.com/returns
ERROR [order-shipped] Relative or malformed URL; an inbox has no base URL
  link "Account" -> /account
ERROR [order-shipped] Uses http: instead of https:
  link "Help" -> http://acme.com/help

5 errors, 0 warnings

Requests are deduplicated by URL because the same help and logo links appear in every template. The concurrency limit of four keeps the run from sending a burst of requests to a single host.


Network checks without side effects

Static rules cannot tell you that /help/billing was renamed last sprint. For that you need a request. The checker below tries HEAD first, falls back to GET when a server does not implement it, follows redirects by hand so it can count them, and never downloads a page body.

lib/check-url.ts
export type UrlCheck =
  | { ok: true; url: string; status: number; finalUrl: string }
  | { ok: false; url: string; status?: number; reason: string };

const MAX_REDIRECTS = 5;

export async function checkUrl(url: string, timeoutMs = 8000): Promise<UrlCheck> {
  let current = url;

  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
    let res: Response;
    try {
      res = await request(current, "HEAD", timeoutMs);
      // Some servers and CDNs don't implement HEAD.
      if (res.status === 405 || res.status === 501) {
        res = await request(current, "GET", timeoutMs);
      }
    } catch (error) {
      return { ok: false, url, reason: describeError(error) };
    }

    const location = res.headers.get("location");
    if (res.status >= 300 && res.status < 400 && location) {
      current = new URL(location, current).toString();
      continue;
    }
    if (res.status >= 400) {
      return { ok: false, url, status: res.status, reason: `HTTP ${res.status}` };
    }
    return { ok: true, url, status: res.status, finalUrl: current };
  }

  return { ok: false, url, reason: `More than ${MAX_REDIRECTS} redirects` };
}

async function request(url: string, method: "HEAD" | "GET", timeoutMs: number) {
  const res = await fetch(url, {
    method,
    redirect: "manual",
    signal: AbortSignal.timeout(timeoutMs),
    headers: { "user-agent": "email-link-check/1.0" },
  });
  // Only the status and headers matter; don't download page bodies.
  await res.body?.cancel();
  return res;
}

// Node's fetch wraps DNS and TLS failures in error.cause.
function describeError(error: unknown): string {
  if (!(error instanceof Error)) return String(error);
  return error.cause instanceof Error ? error.cause.message : error.message;
}

In Node, redirect: "manual" returns the actual 3xx response with its Location header, which is what the loop reads. Browsers behave differently, so run this in Node or in CI, not in a client component. The describeErrorhelper turns a generic "fetch failed" into something useful, like getaddrinfo ENOTFOUND cdn.acme.com.

What not to request

An automated GET is not a harmless read if the endpoint behind it changes state. The runner's isSafeToRequest skips two categories, and you may need to add your own:

  • Unsubscribe links. RFC 8058 one-click unsubscribe uses POST, but plenty of older footer links unsubscribe on GET. A link checker that follows them will quietly opt out whichever fixture address the URL encodes.
  • One-time tokens. Requesting a reset or magic-link URL can consume the token, trip a lockout, or fill your auth logs with failed attempts. Check that the route exists some other way, such as a route manifest test, rather than hitting it with a token.
  • Anything that acts on GET. If a link in your email approves, confirms, or deletes something on GET, the checker is the least of your problems. Mail security scanners request those URLs too. The approval email guide covers the confirmation-page pattern that avoids it.

Reading the results

A 401, 403, or 429 from a site behind bot protection does not mean the page is gone, which is why the runner reports those as warnings. A link into an authenticated part of your app will usually redirect to the sign-in page and return 200 there. That proves the route answers; it can't prove the signed-in destination renders. If that matters, test the destination with an authenticated end-to-end test instead of a link check.

Don't run network checks on every pull request. External sites time out, carriers rate-limit, and staging goes down for reasons unrelated to the change being reviewed. A PR that fails because a third-party page was slow teaches people to ignore the check. Run network checks on a schedule and send the results to a channel.


A last check in the send path

CI checks fixtures. Production sends real data, and real data finds the branch you didn't write a fixture for. Running the static rules on the rendered HTML right before the provider call costs very little and stops the worst case: a customer receiving a reset button that goes nowhere.

lib/send-email.ts
import type { ReactElement } from "react";
import { render, toPlainText } from "react-email";
import { Resend } from "resend";
import { extractLinks } from "./email-links";
import { findLinkProblems } from "./link-rules";

const resend = new Resend(process.env.RESEND_API_KEY);
const allowedHosts = ["acme.com", "app.acme.com", "cdn.acme.com"];

export class EmailLinkError extends Error {
  readonly reasons: string[];

  constructor(subject: string, reasons: string[]) {
    super(`Blocked "${subject}": ${reasons.join("; ")}`);
    this.name = "EmailLinkError";
    this.reasons = reasons;
  }
}

interface SendEmailInput {
  from: string;
  to: string;
  subject: string;
  react: ReactElement;
  idempotencyKey: string;
}

export async function sendEmail({ from, to, subject, react, idempotencyKey }: SendEmailInput) {
  const html = await render(react);
  const errors = extractLinks(html)
    .flatMap((link) => findLinkProblems(link, { allowedHosts }))
    .filter((problem) => problem.severity === "error");

  if (errors.length > 0) {
    throw new EmailLinkError(
      subject,
      errors.map((p) => `${p.reason} (${withoutQuery(p.link.url)})`),
    );
  }

  const { data, error } = await resend.emails.send(
    { from, to, subject, html, text: toPlainText(html) },
    { idempotencyKey },
  );
  if (error) throw new Error(`Resend rejected "${subject}": ${error.message}`);
  return data;
}

// Reset and magic-link tokens live in the query string; keep them out of logs.
function withoutQuery(raw: string): string {
  const cut = raw.search(/[?#]/);
  return cut === -1 ? raw : raw.slice(0, cut);
}

Three details in that function are deliberate:

  • Only static rules run here. A network request at send time adds latency and a new way for sending to fail. Keep HTTP checks in CI.
  • The error message strips query strings. The URLs most likely to fail this check are the ones carrying reset tokens. Logging them in full would put working credentials in your error tracker.
  • Plain text is derived from the same HTML. Because toPlainText converts the checked HTML, the text part has the same links. If you write plain-text versions by hand, check those separately.

Decide per email whether a failed check blocks the send. For a password reset, not sending is better than sending a dead link: the user can press the button again, and your alert fires on the first failure. For a receipt, you may prefer to send and alert, since the amount and the order number are still correct without the help link. Catch EmailLinkError in the caller and choose. The idempotencyKey is passed through to Resend's idempotency support, which deduplicates retries for 24 hours.


Run it in CI

Two triggers cover both kinds of check. Static rules run on pull requests that touch templates or URL helpers. The network pass runs each morning against staging.

.github/workflows/email-links.yml
name: Email links

on:
  pull_request:
    paths:
      - "emails/**"
      - "lib/app-url.ts"
      - "lib/email-links.ts"
      - "lib/link-rules.ts"
  schedule:
    - cron: "0 6 * * *"

jobs:
  check:
    runs-on: ubuntu-latest
    env:
      APP_URL: https://staging.acme.com
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - name: Static link rules
        if: github.event_name == 'pull_request'
        run: npx tsx scripts/check-email-links.tsx
      - name: Static rules and network checks
        if: github.event_name == 'schedule'
        run: npx tsx scripts/check-email-links.tsx --network

If you already run visual regression for your templates, add the link check as a separate job. A screenshot diff will not show you that a URL changed, and a link check will not notice that a button turned white on white. The two failures show up in different places.


What the checker cannot see

Everything above inspects HTML before it leaves your server. Two things rewrite links after that point:

  • Your provider's click tracking. When tracking is on, links are replaced with tracking redirects at send time. Your check validated the destination, not the redirect. Open one real message per template after enabling tracking and click through. The tracking guide covers the tradeoffs, and this post on URL patterns covers the redirect shapes that hurt deliverability.
  • The recipient's security layer. Microsoft's Safe Links documentation says URLs are scanned before delivery and rewritten to safelinks.protection.outlook.com when rewriting is on. A link that passed your check can still be blocked there, and a link that acts on GET can be triggered by the scan.

The checker also has no opinion on whether a link is the right one. A valid URL to the wrong help article passes every rule. The label in the report helps a reviewer spot that, which is another reason to keep it.

Do
  • Check the HTML that render() returns, with fixtures built by your real URL helper
  • Fail pull requests on static errors; run network checks on a schedule
  • Skip unsubscribe URLs and token-bearing links when making requests
  • Strip query strings before logging a blocked URL
  • Add a fixture for every branch that renders different links
Avoid
  • Grepping .tsx source for href values
  • Fixtures with hand-typed URLs that bypass the production URL builder
  • Making HTTP requests from the send path
  • Treating a 403 from bot protection as a dead page
  • In-page anchors and relative paths in any email
Key takeaway
  • Render first, then check. Links are props, so only the rendered HTML tells you what a recipient gets.
  • Static rules for missing URLs, anchors, unfilled values, placeholder hosts, and HTTP run in milliseconds. Use them in CI and before every send.
  • Network checks find renamed routes but are slow and flaky. Schedule them, skip anything with side effects, and treat 401/403/429 as "check manually."
  • Build fixture URLs with your production helper, one fixture per branch.
  • After enabling click tracking, click through one received message per template.
R

React Emails Pro

Team

Building production-ready email templates with React Email. Writing about transactional email best practices, deliverability, and developer tooling.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates