Four rows joined the Mega Analyzer Crawl Health section in the September 2026 wave, and all four report the same kind of defect: something that was correct during the build and wrong the moment the site went live. 2 link(s) or asset(s) point at a dev, staging or placeholder host is the loudest of them. Page redirects via meta refresh (0s) instead of an HTTP 301, 3 internal link(s) carry tracking parameters (utm_, fbclid, gclid) and 2 affiliate / paid link(s) without rel="sponsored" are the other three. None of them break a page in a browser you are already logged into, which is exactly why they survive launch.
What the check actually tests
Dev leftovers. The analyzer collects every a[href], img[src], script[src], link[href], iframe[src], source[src], video[src] and form[action] in the fetched HTML, skips anything inside a <noscript> block, and tests each value against one host pattern. The pattern is anchored to the start of the value and requires a scheme or a protocol-relative //, so a relative path such as /staging/preview.html can never trip it. What does trip it: localhost, 127.0.0.1, 0.0.0.0, [::1], any host ending in .local, .test or .localhost, any host whose first label is dev, staging, stage, test, uat or preview, an ngrok or localtunnel URL, a netlify.app or vercel.app deploy host, the placeholder image services (placehold.co, placehold.it, via.placeholder.com, placeholder.com, picsum.photos, dummyimage.com, placekitten.com, lorempixel.com), and example.com, example.org or example.net. An optional port is allowed, and the host has to be followed by a slash, a query, a fragment, or the end of the string.
Two things about that list are worth knowing before you argue with the row. The subdomain rule reads the first label only, so staging.example.com fails while www.staging.example.com, staging-api.example.com and qa.example.com all pass. And the whole detector is skipped when the page's own canonical points at a netlify.app or vercel.app host, because a deploy preview that canonicalizes to itself has nothing but preview links and the row would be noise. The skip reads the canonical, not the URL you typed, so a preview build that declares its production canonical still gets the full check and lights up on its own preview host. Run this one against production either way.
The fail row is warn severity, names up to four of the offending values in its detail line, and keeps at most six unique ones. The pass wording is No links or assets point at dev, staging or placeholder hosts, and it renders only when the page had links to inspect in the first place.
Meta refresh redirect. The analyzer takes the first meta[http-equiv="refresh"] in the document, case insensitive on the attribute value, reads its content, parses the leading integer as the delay, and pulls the target out of a url= segment. The row renders only when there is a url=. A bare <meta http-equiv="refresh" content="300">, the auto reload some dashboards use, produces nothing, because that is a refresh and not a redirect. There is no pass row either: silence is the pass. A content that starts with url= and gives no number is reported as (0s), since the delay parse falls back to zero.
Tracking parameters on internal links. Every a[href] is resolved against the page's canonical URL, and a link counts as internal when its hostname is exactly equal to the canonical's hostname. The query is then tested for utm_ followed by letters, fbclid, gclid, msclkid, mc_cid or mc_eid, each as a real parameter with an =. Up to six offenders are kept and the first three are printed.
Two consequences of using the canonical as the definition of "your host". A link from example.com to www.example.com is external to this check, tagged or not. And if the page has no canonical at all, the comparison falls back to a placeholder origin, which only relative hrefs resolve to: a tagged link written as /pricing/?utm_source=nav is still caught, while the same link written as a full https:// URL on your own domain is skipped. Missing your canonical makes this row under-report, not over-report, so fix the canonical first and re-run. One more narrow point: the pattern reads letters after utm_, so an underscored variant like utm_source_platform is not matched on its own, though it never travels alone.
Affiliate links rel=sponsored. The analyzer looks at outbound a[href] only, again using the canonical to decide what "outbound" means, and keeps the ones that match a known affiliate network or a telltale query parameter: amzn.to, an Amazon URL carrying a tag= parameter, ShareASale, Awin, the Commission Junction redirect domains, Rakuten LinkShare, Impact, ClickBank, Partnerize, Skimlinks, VigLink, or any link carrying an aff, affiliate, aff_id, affid, affiliate_id or irclickid query parameter. A bare ref, referral or partner parameter is not on that list and does not trip the row, which surprises people who expect ?ref= to be read as an affiliate tell. Each candidate's rel attribute is then read for the whole word sponsored or nofollow. If every candidate has one of them, the row passes as Affiliate / paid links carry rel="sponsored" (2) with the total count. If any candidate has neither, it fails and names the first one. rel="ugc" is not accepted by this row. When the page has no candidates at all, neither row renders.
The gap here is timing. The analyzer reads served HTML, so a link rewriter that converts plain outbound links into affiliate links in the browser (which is how Skimlinks and VigLink normally work) is invisible to it. Your served markup passes and your readers still get monetized links. The same limit applies to the tracking row: parameters appended by a script after load are not in the HTML the check reads.
Why it matters
A staging link is the launch mistake nobody sees, because the person who made it is the one person in the world for whom it still works. The staging host is in their hosts file, or their DNS resolver still caches it, or they are on the VPN where it resolves. Everyone else gets a dead link, a broken image, or a spinner. The worst variant is form[action]: a contact form posting to a staging endpoint looks completely normal to the visitor, accepts the submission, and drops every lead on the floor. Nothing in the page reports an error, so the finding arrives weeks later as "we stopped getting inquiries."
If the staging host is publicly reachable and indexable, the problem inverts: you have handed crawlers a path into a second copy of the site. Google chooses one URL from a set of duplicates as the canonical, and the one it picks is the one searchers see, which is a coin flip you did not intend to enter. Placeholder image services have a quieter cost. Every one of them is a third-party request from your production page, it carries your visitor's IP and referrer to a service you have no relationship with, and when that service rate limits you or goes away, the image goes with it.
Meta refresh is different: it works, and it is documented as working. Google's redirect guidance lists meta refresh among the redirect types it supports, says an instant one is interpreted as a permanent redirect and a delayed one as a temporary redirect, and still recommends server side redirects where you can use them. The reason is mechanical. A 301 lives in the response header, so a crawler learns the new location from a few hundred bytes and never parses a body. A meta refresh lives inside HTML, so the old URL has to be fetched and parsed in full before the redirect is even discovered, on every crawl, forever. A delayed refresh has a second cost: it moves the reader without asking, which is the kind of content-imposed time limit WCAG's Timing Adjustable criterion exists to constrain.
Campaign parameters on your own internal links are the one item here that is wrong in two systems at once. In search, /pricing/?utm_source=homepage and /pricing/ are two URLs with the same content, which is the textbook duplicate Google has to canonicalize; you can end up with the tagged copy indexed and shown in results. In analytics it is worse, because a new campaign value is a new acquisition source. Google Analytics documentation is direct about this: do not add campaign parameters to internal links, because doing so overwrites the original source of the session. The visitor who arrived from a paid ad clicks your own tagged nav link and is recorded from then on as coming from the homepage. The channel report then shows internal traffic attributed to itself, and the ad that actually paid for the visit shows a worse cost per conversion than it earned.
Affiliate rel is the only row here that is policy rather than mechanics. Google asks that paid and affiliate links be qualified with rel="sponsored", accepts nofollow for the same purpose, and lists links that pass ranking credit in exchange for payment under link spam in the spam policies. That is a ranking signal, not a consumer disclosure, and the two are often confused. The FTC's Endorsement Guides require a clear and conspicuous disclosure of a material connection between an endorser and a seller, and an affiliate commission is a material connection. Adding rel="sponsored" satisfies Google and tells your reader nothing, because nobody reads your markup. You need both: the attribute for the crawler, a sentence in the visible copy for the person.
How to fix it
Step 1: find every dev leftover in the built output, not in the source. Templates are not where these hide; environment variables and inline content are. Grep the build directory, and make it a gate rather than a habit:
// scripts/check-dev-leftovers.mjs (run before every deploy; exit 1 blocks it)
import fs from 'node:fs';
import path from 'node:path';
const HOST_RE = /(?:https?:)?\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|[^/"'\s]*\.(?:local|test)\b|(?:dev|staging|stage|test|uat|preview)\.[^/"'\s]+|[^/"'\s]*\.ngrok(?:-free)?\.app|[^/"'\s]*\.netlify\.app|placehold\.(?:co|it)|via\.placeholder\.com|picsum\.photos|dummyimage\.com|example\.(?:com|org|net))/i;
const hits = [];
function walk(dir) {
for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, f.name);
if (f.isDirectory()) { walk(p); continue; }
if (!/\.(html|css|js|xml|json|txt)$/.test(f.name)) continue;
fs.readFileSync(p, 'utf8').split('\n').forEach((line, i) => {
const m = line.match(HOST_RE);
if (m) hits.push(p + ':' + (i + 1) + ' ' + m[0]);
});
}
}
walk('_site');
if (hits.length) { console.error(hits.join('\n')); process.exit(1); }
console.log('dev-leftover check: clean');
Add your own staging host to the pattern by name. The generic rule catches staging.example.com, it does not catch qa7.example.com, and yours is probably the second kind. Then flush your own hosts file and DNS cache and load the site from a phone on cellular. That is the fastest way to see the site the way a stranger does.
Step 2: replace the meta refresh with a server side redirect. This is the tag the row found:
<meta http-equiv="refresh" content="0; url=https://example.com/pricing/">
Delete it and move the redirect to the host. On Netlify, a line in _redirects, forced so it wins while the old file still exists:
/old-pricing/ /pricing/ 301!
On Apache, one directive in .htaccess:
Redirect 301 /old-pricing/ https://example.com/pricing/
On nginx, an exact location match:
location = /old-pricing/ { return 301 https://example.com/pricing/; }
Then confirm the result is a single hop. A meta refresh replaced by a 301 that lands on another 301 has traded one problem for a smaller one. Paste the old and new URLs into the Redirect Chain Audit, which follows every hop, reports the final status, and flags chains past two hops. If you are generating a batch of these from an old-to-new URL map, the Broken Link Fix Generator emits the Netlify, Apache, nginx and Vercel versions of the same rules in one pass.
Step 3: strip campaign parameters from internal links. Campaign parameters belong on links that point at your site from somewhere else: an ad, an email, a partner page, a QR code. Build those with the UTM Builder and keep them out of your own navigation, footers, banners and cross-sell blocks. To find the ones already in place on more than the page in front of you, feed a list of URLs to Mega Batch, which runs these same rows across up to ten pages at once and lines the results up side by side.
When you tagged internal links because you wanted to know which placement drove the click, the replacement is an event, not a URL:
<a href="/pricing/" data-placement="homepage-hero">See pricing</a>
document.querySelectorAll('a[data-placement]').forEach(a => {
a.addEventListener('click', () => {
if (window.gtag) gtag('event', 'internal_link_click', {
placement: a.dataset.placement,
link_url: a.getAttribute('href')
});
});
});
That records the placement without minting a second URL for the destination and without resetting the session's acquisition source. If tagged internal URLs are already indexed, add a self-referencing canonical on the clean URL and let the parameter copies fall out of the index as Google regroups them with their canonical.
Step 4: mark the paid links. The attribute goes on the anchor, and noopener belongs there too if the link opens a new tab:
<a href="https://retailer.example/product?aff_id=yoursite-20"
rel="sponsored noopener"
target="_blank">the cordless drill I actually own</a>
Use rel="sponsored nofollow" if you want both signals on the link. Add the visible disclosure separately, near the top of the page or immediately before the first link, in your own words. Then handle the links you did not write: if a plugin, a widget, or a link rewriter is inserting affiliate links, the fix is in that tool's settings, not in your template, and the analyzer will keep passing the page either way because it never sees them.
When to leave it alone
The example.com family is the honest exception to the dev leftover row, and it is an exception with a spec behind it. RFC 2606 reserves example.com, example.net and example.org, along with the .test, .example, .invalid and .localhost top level domains, precisely so documentation can use them without hitting a real host. A tutorial page, an API reference, or an article like this one is supposed to contain them. The analyzer cannot tell a documentation page from a marketing page, so on a docs site this row will be red forever and should be. If you add the build gate above, give it an allowlist for your documentation paths on day one, or you will disable the whole gate the first time it blocks a legitimate deploy.
The rest of the dev list needs the opposite caution. It is a list, not a heuristic. A pass means none of those specific hosts appeared, not that your own preview infrastructure is absent, so qa.example.com and preview-7.example.com sail through. The vercel.app entry is narrower than it looks as well: it only matches a bare deploy root ending in a slash, so a link to a page inside a Vercel preview goes unreported, while the netlify.app entry has no such limit. Add your names.
Meta refresh is legitimate when you have no access to the server's redirect layer. A static file host with no redirect configuration, a locked-down CMS, a page served from a bucket: on all of those, an instant meta refresh is a documented and supported redirect, and it is better than leaving the old URL as a dead end. Keep the delay at zero, and treat the row as a note to revisit when the hosting changes. A refresh with no url= never fires the row at all, so a dashboard that reloads itself every five minutes is not this check's business.
Tracking parameters have one real carve-out, and it is not internal navigation. If you run several properties on different hostnames and want cross-property attribution, links between them are outbound by this check's definition and never reach the row. Inside one hostname there is no version of this that is correct, which is why the analyzer treats it as binary.
The affiliate row is the noisiest of the four, and the reason is that three of its network names (partnerize, impactradius, tkqlkt) are matched as bare text anywhere in the URL rather than as hostnames. A post whose slug reviews one of those networks matches. So does a link to the Impact corporate site, and so does an ordinary ?aff= parameter that happens to mean "affiliation" in the academic sense. Adding rel="sponsored" to a link that is not sponsored is a false statement about your own markup, so read the reported URL before you act. If the link is genuinely unpaid, the row is a false positive and the right answer is to leave the markup alone. The other direction is deliberate too: rel="nofollow" alone passes, so an older site that marked everything nofollow is not required to migrate.
Fact-check notes and sources
- Source: https://developers.google.com/search/docs/crawling-indexing/qualify-outbound-links establishes the
rel="sponsored",rel="ugc"andrel="nofollow"values, thatsponsoredmarks advertisements and paid placements, and that Google treats the values as hints for ranking purposes. - Source: https://developers.google.com/search/docs/essentials/spam-policies#link-spam establishes that buying or selling links for ranking purposes, and advertisements or paid links that pass ranking credit, are examples of link spam.
- Source: https://support.google.com/analytics/answer/10917952 establishes the campaign parameters collected by Google Analytics and the instruction not to add them to links within your own site, because a new campaign value overwrites the original source of the session.
- Source: https://developers.google.com/search/docs/crawling-indexing/301-redirects establishes the redirect types Google supports, that an instant meta refresh redirect is interpreted as a permanent redirect and a delayed one as a temporary redirect, and the recommendation to use server side redirects where possible.
- Source: https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-http-equiv-refresh establishes the
refreshpragma directive, and that itscontentvalue is a time in seconds optionally followed by aurl=target the user agent navigates to. - Source: https://developers.google.com/search/docs/crawling-indexing/canonicalization establishes that duplicate URLs are grouped and one is chosen as the canonical, which is what a parameterized copy of an internal URL becomes.
- Source: https://www.rfc-editor.org/rfc/rfc2606 establishes that
example.com,example.netandexample.org, and the.test,.example,.invalidand.localhosttop level domains, are reserved for documentation and testing use. - Source: https://www.ftc.gov/business-guidance/resources/ftcs-endorsement-guides-what-people-are-asking establishes that a material connection between an endorser and a seller, including affiliate commission, calls for a clear and conspicuous disclosure to the audience.
- Source: https://www.w3.org/TR/WCAG22/#timing-adjustable establishes the Timing Adjustable success criterion for time limits set by the content, which covers a delayed redirect.
- The host list, the first-label subdomain rule, the
noscriptexclusion, the result caps, the canonical-derived definition of "internal" and "outbound", and the exact pass and fail wording all come from the Mega Analyzer source at https://jwatte.com/tools/mega-analyzer/ (view source; search forgap_dev_leftovers,gap_meta_refresh,gap_tracking_paramsandgap_affiliate_rel).
Related reading
- Unfinished site tells: hello world slugs in your sitemap, lorem ipsum in production
- Which URL is the real one? Off domain canonicals and sitemaps with two homepages
- Every redirect hop beyond the first leaks PageRank
- UTM tagging: why mixed case source tags wreck your GA4 channel grouping
All four of these are the same species of bug: the site works for the person who built it and not for anyone else. That gap between "it works on my machine" and "it works for a stranger on cellular data" is the single most expensive thing a first launch gets wrong, and it is what The $97 Launch spends a chapter on.
This post is informational, not legal advice. Mentions of third parties are nominative fair use. No affiliation is implied.