The sitemap was clean, the status codes were right, canonicals were in place everywhere. The content site runs on Next.js and I built it myself, so when I pointed an SEO crawler at it, the only thing I expected to read was a short list of mistyped internal links.
What the crawler returned was not a short list.
The symptom: 18,000 URLs I never meant to create
The crawler found roughly 18,000 crawlable URLs matching a ?range= pattern, plus about 300 pages matching ?cat=. Every one of them was reported as a legitimate URL worth visiting.
/detail/aaa?range=24h
/detail/aaa?range=7d
/detail/aaa?range=30d
/detail/bbb?range=24h
/detail/bbb?range=7d
...Finding the source took five minutes. Detail pages had a time range switcher for their chart, and I had built that switcher out of real anchors instead of buttons:
{ranges.map((r) => (
<Link key={r} href={`?range=${r}`} scroll={false}>
{r}
</Link>
))}At the time that felt like the correct decision. Real anchors mean the selected range is shareable, opens in a new tab, and still works without JavaScript. What I had not counted was that every one of those anchors is, to a crawler, a new URL. Multiply the number of detail pages by the number of range options and the count jumps into five figures immediately.
The confusing part was not the number. The confusing part was that every one of those variants already had a correct canonical.
Why I thought I was covered
Each detail page emitted a canonical pointing at its clean, parameterless version:
<link rel="canonical" href="https://your-site.com/detail/aaa" />So my reasoning went: the canonical is correct, therefore Google knows which version is real, therefore the parameter variants are not a problem. Case closed.
That reasoning was correct for the wrong question. The canonical was saving my index. What it was not saving was my crawl budget, and those are two separate budgets.
The root cause: canonical and robots answer different questions
This is the whole case, and it took an 18,000 URL audit before it really landed:
- Canonical answers: "which URL deserves to be in the index?" To read that answer, the crawler has to download the page first. Which means every one of those 18,000 URLs still gets fetched, still costs a request, still costs server time, and only then gets dropped from index consideration.
- robots.txt answers: "are you allowed to fetch this URL at all?" That answer is given before the request happens, so it genuinely cuts the crawl.
And here is the trap that makes people ship both and then wonder why nothing improved: the two do not stack. If you Disallow a URL, the crawler never fetches it, which means it never reads the canonical tag inside it. A canonical that is never read is the same as no canonical.
So for any single URL, you are choosing between:
- Let it be crawled, canonical gets read, index stays clean, crawl budget burns.
- Block it in robots, crawl budget is saved, canonical is never read.
There is no third option that gives you both. The only thing to decide is which cost is higher for this class of URL.
Why it only bites at a certain scale
On a small site this debate is academic. A hundred parameter variants will never starve anyone's crawl budget.
What changes on a large content site is cardinality. Faceted parameters attach to pages that are already numerous, and they multiply rather than add. One parameter with three options turns a few thousand pages into tens of thousands of URLs. Add a second parameter on those same pages and the combinations multiply again, and a six figure URL count becomes ordinary without a single new page ever being written.
At that point the crawler spends its visit allowance reading thousands of copies of the same page with a different chart window, while the genuinely new pages queue up behind them. Those variant pages do stay out of the index. They have simply already charged you before that happens.
The fix: block the patterns in app/robots.ts
Since these parameter variants have no index value whatsoever, the choice was easy. I was happy to give up the canonical being read. What I wanted was the crawling to stop.
Before writing anything, one mandatory check: make sure no legitimate path on the site depends on those parameter names. If even one route needs range or cat to render content that has to be indexed, this rule will bury it.
grep -rn -A3 "searchParams" app/ | grep -E "range|cat"The -A3 is not padding. The parameter name rarely sits on the same line as searchParams; it usually shows up in the destructuring or the type annotation a line or two below. Without those context lines the grep comes back empty and you feel safe without having checked anything.
On this site the only hits were the chart switcher and a category filter on a listing page that already had a parameterless version. Safe. Only then did I ship the rules:
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/*range=", "/*cat="],
},
],
sitemap: "https://your-site.com/sitemap.xml",
};
}The leading wildcard matters. Disallow: /*range= matches that parameter at any path depth, not just at the root, and that is exactly what you need when the switcher lives on detail pages.
Verification is simple, and do it against production, not locally:
curl -s https://your-site.com/robots.txtIf both Disallow lines actually appear in the output, you are done. Clean pages remain crawlable as usual because allow: "/" still applies, and only the parameterised variants are cut off.
The better fix: don't create the URLs in the first place
Blocking a pattern in robots.txt is treatment, not a cure. The URLs still exist. I have only told the crawler not to come.
The cleaner approach is never producing those URLs at all. If the state is purely a display convenience and does not change indexable content, move it into a fragment. Fragments never become separate URLs to a crawler:
const [range, setRange] = useState("24h");
useEffect(() => {
const fromHash = window.location.hash.slice(1);
if (ranges.includes(fromHash)) setRange(fromHash);
}, []);
{ranges.map((r) => (
<button
key={r}
type="button"
aria-pressed={r === range}
onClick={() => {
setRange(r);
history.replaceState(null, "", `#${r}`);
}}
>
{r}
</button>
))}You still get a link you can copy and share, #24h rides along in the address bar, but the crawler does not see 18,000 pages. It sees one.
The useEffect above is what makes a copied link actually restore the range, and that restore has to happen after mount. A fragment is never sent to the server, so reading it in the initial state makes the server render disagree with the client render and you get a hydration mismatch. One more caveat so you are not surprised: replaceState deliberately does not add a history entry, so the back button will not step through each range change. Use pushState if that is the behaviour you want.
The price is explicit too: this button version needs JavaScript, which the anchor version did not. For state that only changes the view, that is a fair trade. For state that changes indexable content, do not move it into a fragment at all.
The difference is what each form declares. A ?range= anchor tells the crawler "this is a different document". A #24h fragment tells it "same document, different view". For a chart switcher, the second sentence was the true one all along.
What I took away
- Canonical and robots.txt are not two layers of the same defence. They answer different questions, and stacking them on the same URL cancels one of them out.
- A correct canonical stops zero crawler requests. If you want the fetching to stop, robots.txt is the only tool for it.
- Faceted parameters multiply your URL count rather than adding to it. Multiply page count by option count before deciding to use real anchors.
- Before shipping a pattern based
Disallow, grep every route that reads that parameter name. A one line rule can hide a page you actually need. - If state only changes the view and not the content, keep it in a fragment. A URL that never exists is a URL you never have to manage.