DEV Community

Cover image for Following Redirect Chains That Break Link Previews: Cap the Hops, Prefer Absolute Location Headers, and Read the Final HTML
Abid Ali
Abid Ali

Posted on

Following Redirect Chains That Break Link Previews: Cap the Hops, Prefer Absolute Location Headers, and Read the Final HTML

A page can look perfect in the browser and still share as the wrong card. The usual suspect is a redirect. The address in the chat is not the address that answers with the Open Graph tags. Something in the middle returns a Location header, the crawler follows it, and the tags it finds belong to a different page — the old URL, the bare domain, a login wall, or a soft 404 that still says 200.

This post is a practical walk through that problem in Node.js. You will get a small helper that follows a redirect chain with a hop limit, prefers absolute Location values, refuses private network targets, and prints the final share tags. You will also get a short checklist for when a card keeps showing the wrong title after a move.

I built OG Scope as a set of free tools for this kind of check. The redirect checker follows a URL hop by hop and shows each status code and the final page. The code below is the same idea, shrunk so you can drop it into a script or a CI step.

What a preview crawler actually sees

A browser loads your page with cookies, JavaScript and a long history of clicks. A preview crawler does none of that. It sends a GET (sometimes with a bot user agent), reads the first response, and if that response is a redirect it follows Location. It stops when it gets a non-redirect status, then looks for og:title, og:description, og:image and friends in the HTML.

That means three things:

  1. The URL you pasted is only the start. The tags come from wherever the chain ends.
  2. Client-side redirects do not count. A meta refresh, a window.location assignment, or a framework router that only runs in the browser will not move most crawlers. The tags must be on the HTML the server returns.
  3. Relative Location headers are easy to get wrong. Location: /blog/new-slug is legal, but every hop has to resolve it against the current URL. If your helper (or your proxy) resolves it against the wrong base, you land on a different host.

A permanent move should usually be a 301. A temporary one is a 302 or 307. The 301 vs 302 guide on OG Scope covers the difference in plain language. For share tags, the bigger risk is not which code you pick — it is whether the final page is the one you meant.

A small follower that refuses to wander

Here is a Node helper with no dependencies. It follows redirects up to a fixed hop count, resolves relative Location values with the WHATWG URL constructor, and refuses private or loopback targets so you do not accidentally turn it into an open proxy against your own network.

import { isIP } from 'node:net';

const MAX_HOPS = 5;
const TIMEOUT_MS = 8000;
const UA = 'ShareTagRedirectCheck/1.0 (+local-dev)';

const PRIVATE = [
  /^127\./, /^10\./, /^192\.168\./, /^169\.254\./,
  /^172\.(1[6-9]|2\d|3[0-1])\./, /^0\./, /^::1$/, /^fc/i, /^fd/i, /^fe80:/i,
];

function assertPublicHostname(hostname) {
  const host = hostname.replace(/^\[|\]$/g, '');
  if (host === 'localhost' || PRIVATE.some((re) => re.test(host))) {
    throw new Error(`Refusing private host: ${hostname}`);
  }
  if (isIP(host) && PRIVATE.some((re) => re.test(host))) {
    throw new Error(`Refusing private IP: ${host}`);
  }
}

export async function followToHtml(startUrl) {
  let current = new URL(startUrl);
  const hops = [];

  for (let i = 0; i <= MAX_HOPS; i++) {
    assertPublicHostname(current.hostname);

    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
    let res;
    try {
      res = await fetch(current, {
        method: 'GET',
        redirect: 'manual',
        headers: { 'user-agent': UA, accept: 'text/html,*/*' },
        signal: ctrl.signal,
      });
    } finally {
      clearTimeout(timer);
    }

    hops.push({ url: current.href, status: res.status });

    if ([301, 302, 303, 307, 308].includes(res.status)) {
      const loc = res.headers.get('location');
      if (!loc) throw new Error(`Redirect ${res.status} without Location at ${current}`);
      // Absolute Location wins. Relative ones resolve against the current hop.
      current = new URL(loc, current);
      continue;
    }

    if (res.status !== 200) {
      return { hops, finalUrl: current.href, status: res.status, html: null };
    }

    const ctype = (res.headers.get('content-type') || '').toLowerCase();
    if (!ctype.includes('text/html') && !ctype.includes('application/xhtml')) {
      return { hops, finalUrl: current.href, status: res.status, html: null, contentType: ctype };
    }

    const html = await res.text();
    return { hops, finalUrl: current.href, status: 200, html };
  }

  throw new Error(`Too many redirects (>${MAX_HOPS}) starting at ${startUrl}`);
}
Enter fullscreen mode Exit fullscreen mode

Two details matter in production:

  • redirect: 'manual' — if you let fetch follow redirects for you, you lose the hop list and you cannot inspect each status.
  • Resolve Location with new URL(loc, current) — that is the same rule browsers use. Do not concatenate strings.

Pull the share tags off the final HTML

Once you have the final HTML, read the tags with a tiny parser. Regular expressions are fine for a smoke check; they are not a full HTML parser, so treat the output as a signal, not a proof.

function meta(html, attr, name) {
  const re = new RegExp(
    `<meta[^>]+${attr}=["']${name}["'][^>]+content=["']([^"']*)["'][^>]*>` +
    `|` +
    `<meta[^>]+content=["']([^"']*)["'][^>]+${attr}=["']${name}["'][^>]*>`,
    'i',
  );
  const m = html.match(re);
  return m ? (m[1] || m[2] || '').trim() : null;
}

export function readShareTags(html) {
  return {
    title: meta(html, 'property', 'og:title') || meta(html, 'name', 'twitter:title'),
    description: meta(html, 'property', 'og:description') || meta(html, 'name', 'twitter:description'),
    image: meta(html, 'property', 'og:image') || meta(html, 'name', 'twitter:image'),
    url: meta(html, 'property', 'og:url'),
    siteName: meta(html, 'property', 'og:site_name'),
  };
}
Enter fullscreen mode Exit fullscreen mode

Wire it together:

const result = await followToHtml(process.argv[2]);
console.log(JSON.stringify({ hops: result.hops, finalUrl: result.finalUrl, status: result.status }, null, 2));
if (result.html) console.log(readShareTags(result.html));
Enter fullscreen mode Exit fullscreen mode

Run it against a URL you control. You want three green lights:

  1. The hop list ends on the canonical address you publish.
  2. The final status is 200 with HTML.
  3. og:title, og:description and og:image are present and match that page — not the homepage, not an old slug.

For a faster visual pass on a live URL, paste it into the Open Graph checker. It fetches the public page and shows estimated cards for several apps. Use that after the script, not instead of it, when you care about the hop list.

Failures that look like "random" bad cards

These are the patterns I see most often when a redirect is involved:

1. Old slug → new slug, but og:url still names the old one

The redirect works. The browser lands on the new page. The card still talks about the old address because og:url was hard-coded and never updated. Fix the tag on the final page, then re-check.

2. Apex → www (or the reverse) with different tags on each host

Both hosts answer 200. Only one has the right share image. People paste whichever form they copied. Pick one canonical host, 301 the other, and put the tags only on the canonical HTML.

3. A marketing link shortener in front of the real URL

Shorteners are extra hops. Some crawlers follow them; some cache the first card they ever saw for that short URL. If you must use a short link, check the short URL itself, not only the destination.

4. HTTP → HTTPS mid-chain with a relative Location

Location: /pricing on an HTTP response, resolved badly by a middlebox, can send a crawler to the wrong place. Prefer absolute HTTPS Location values on every hop.

5. Soft 404 at the end of a 302

The chain ends on a page that says "not found" in the body but returns 200 with the site's default og:title. The card looks "fine" and completely wrong. Return a real 404 (or 410) for gone URLs, and give that error page its own honest tags.

A CI-friendly assertion

Once the helper exists, the check in CI is boring on purpose:

import assert from 'node:assert/strict';
import { followToHtml, readShareTags } from './redirect-share.js';

const { hops, finalUrl, status, html } = await followToHtml('https://www.example.com/old-slug');
assert.equal(status, 200);
assert.equal(finalUrl, 'https://www.example.com/new-slug');
assert.ok(hops.length >= 2, 'expected at least one redirect hop');
const tags = readShareTags(html);
assert.ok(tags.title && tags.image, 'final HTML missing og tags');
assert.equal(tags.url, finalUrl);
Enter fullscreen mode Exit fullscreen mode

Keep the allow-list of start URLs small. This is a smoke test for the pages you just moved, not a full site crawl.

Checklist before you announce a moved URL

  1. Curl the old address with -I and confirm a single, intentional redirect to the new one.
  2. Curl the new address and confirm 200 plus the share tags you expect.
  3. Run the helper above and read the hop list.
  4. Paste both URLs into a checker and compare the cards.
  5. If a chat app still shows the old card, wait for its cache — or share a URL the app has never seen (a query form you will later 301), rather than assuming the tags are wrong.

Redirects are not glamorous. They are also where a lot of "our preview is broken" reports actually start. Cap the hops, make Location absolute, read the final HTML, and treat the card as part of the move — not something to fix afterwards when the announcement link is already in fifty chats.

Redirects are infrastructure for sharing, not only for browsers. Treat the hop list as part of the publish checklist and the card will stop surprising you.

Top comments (0)