Serving X-Robots-Tag From Edge Middleware
Header directives are the only way to control indexing for responses that never pass through a template — and the fastest way to deindex an entire site if the matcher is one character too broad.
When to Use This Approach
Use a header directive when:
- The response is not HTML — a JSON endpoint, a CSV export, a generated PDF, or a file download — so there is no
<head>to carry a meta tag. - You need a directive on a response your framework does not render, such as a static asset served directly by the CDN.
- An entire non-production hostname must be suppressed, where a per-route approach would miss the endpoints nobody remembers.
For ordinary HTML pages, the directive belongs in the framework metadata layer instead — the reasoning is in robots & crawl directives in headless stacks.
Why Scope Is the Whole Problem
A header directive applies to whatever the matcher catches, and matchers are written once and then forgotten. The diagram contrasts a matcher scoped by response type against one scoped by a path prefix that later grew.
Implementation Steps
Step 1: Enumerate the responses that have no HTML
# Every route the framework builds that is not a page
find app -name 'route.js' -o -name 'route.ts' | sed 's|app||; s|/route\..*||'
# Plus anything served straight from the CDN
aws s3 ls s3://example-assets/exports/ --recursive | awk '{print $4}' | head
Validation: The list should contain only machine-readable endpoints and downloads. If a page route appears, the enumeration is wrong, not the middleware.
Step 2: Decide the directive from the content type
Matching on the response rather than the request is what makes the rule survive a routing change.
// middleware.js
import { NextResponse } from 'next/server';
const DIRECTIVES = [
{ test: (ct) => ct.includes('application/json'), value: 'noindex, nofollow' },
{ test: (ct) => ct.includes('text/csv'), value: 'noindex, noarchive' },
{ test: (ct) => ct.includes('application/pdf'), value: 'noindex, noarchive' },
];
export function middleware(request) {
const res = NextResponse.next();
const ct = res.headers.get('content-type') ?? '';
if (ct.includes('text/html')) return res; // pages are never tagged here
const rule = DIRECTIVES.find((d) => d.test(ct));
if (rule) res.headers.set('X-Robots-Tag', rule.value);
return res;
}
SEO impact: A route that changes from returning JSON to returning a rendered page loses the directive automatically, which is the safe direction for the change to fail in.
Validation: curl -sI https://example.com/api/products | grep -i x-robots-tag returns the directive; the same request to /blog/my-post returns nothing.
Step 3: Restrict the matcher so the middleware barely runs
Content-type checking is the safety net, not the primary control. The matcher should keep the middleware off page routes entirely.
export const config = {
matcher: [
'/api/:path*',
'/exports/:path*',
'/files/:path((?!.*\\.html$).*)',
],
};
SEO impact: Middleware that never executes for a page route cannot regress it, however the logic inside changes later.
Validation: Add a console.log in development, request a page route, and confirm nothing is logged.
Step 4: Handle hostname-wide suppression separately
Suppressing a whole preview host is a different rule with a different shape, and mixing it into the content-type logic is how production ends up noindexed.
const NON_PRODUCTION = /^(preview|staging|deploy-preview-\d+)\./;
export function middleware(request) {
const host = request.headers.get('host') ?? '';
if (NON_PRODUCTION.test(host)) {
const res = NextResponse.next();
res.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive');
return res;
}
return contentTypeRules(request);
}
SEO impact: The hostname test is an allowlist of non-production patterns rather than a denylist of production ones, so an unrecognised host defaults to indexable — production behaviour, which is what you want when the rule is wrong.
Validation: curl -sI -H 'Host: preview.example.com' https://example.com/ | grep -i x-robots-tag.
Step 5: Assert the negative case in continuous integration
The dangerous failure is silent, so the test has to look for the absence of a header rather than its presence.
#!/usr/bin/env bash
# ci/assert-no-stray-noindex.sh
set -euo pipefail
fail=0
while read -r url; do
hdr=$(curl -sI "$url" | grep -i '^x-robots-tag:' || true)
if [ -n "$hdr" ]; then
echo "STRAY DIRECTIVE on indexable URL: $url -> $hdr"
fail=1
fi
done < <(curl -s "$BASE_URL/sitemap.xml" | grep -oP '(?<=<loc>)[^<]+')
exit $fail
Validation: Temporarily broaden the matcher on a branch and confirm the job fails. A check that has never failed has not been tested.
SEO Impact Summary
The blast radius of a mistake here is proportional to how much of the site the matcher covers, which is the argument for keeping it as narrow as possible.
| Signal | What improves | What breaks if misconfigured |
|---|---|---|
| Index composition | JSON endpoints and exports stop competing with real pages for index slots | One over-broad matcher can suppress an entire section within a crawl cycle |
| Crawl efficiency | Crawlers stop re-fetching machine-readable duplicates of page content | Blocking assets needed for rendering makes pages look thin instead |
| Diagnostic clarity | Endpoints stay crawlable and inspectable while being unindexable | A Disallow instead of a header would hide them from your own auditing too |
| Recovery time | Removing a stray header restores indexing at the next crawl | Damage scales with the crawl frequency of the affected section |
Measurable signals to watch:
- Count of indexed URLs matching
/api/or/exports/in Search Console, which should reach zero. - The CI assertion in Step 5, which should never fire.
- Coverage for the sections adjacent to any matcher you change, checked for a week after the change.
Reviewing a Directive Change Before It Ships
Because the failure mode is invisible in the HTML, a middleware change deserves a specific review checklist rather than a generic one.
Edge Cases and Gotchas
Content type is not always set when middleware runs On some runtimes the response headers are not populated until after the handler completes, so a content-type check inside middleware sees an empty string. Verify on your platform; if the header is unavailable, fall back to a file-extension test plus an explicit page-route exclusion.
Redirect responses do not carry useful directives
A directive on a 301 is ignored because crawlers evaluate the destination. If a legacy URL must be suppressed, suppress the destination or return 410 instead — the trade-offs are covered in redirect chain management.
nofollow in a header applies to the whole response
Unlike the link-level attribute, a header nofollow drops every link on the response. Fine for an API, wrong for a page you are suppressing but still want to pass equity through — use noindex, follow there.
CDN rules and middleware can both fire If your CDN also has a page rule adding headers, you have two sources for one directive and no single place to read the answer. Consolidate on one layer and delete the other, rather than documenting the interaction.
Frequently Asked Questions
Why not set X-Robots-Tag on everything and override per page?
Because directives combine to the most restrictive result rather than overriding. A site-wide noindex header cannot be cancelled by a page-level index meta tag — the header wins and the page is suppressed. Default-deny is right for authentication and actively dangerous for robots directives.
Should image and font responses carry a directive?
Fonts and CSS need none and must stay crawlable so Google can render the page. Images are a judgement call: a noindex removes them from image search, which is right for UI sprites and avatars and wrong for product and editorial photography that earns traffic.
Does an X-Robots-Tag on a redirect response do anything?
Effectively no. Crawlers follow the redirect and apply the directives on the final response, so a directive attached to the 301 itself is ignored. Attach it to the destination if you need suppression.
How quickly does removing a stray header restore indexing? At the next crawl of each affected URL, so hours for well-linked pages and weeks for deep ones. There is no way to accelerate it beyond ensuring the URLs are in a fresh sitemap and internally linked, which is why the CI assertion matters more than the recovery plan.
Part of: Robots & Crawl Directives in Headless Stacks
Related
- noindex vs robots Disallow for Headless Routes — choosing between suppression and crawl blocking before reaching for either
- Generating robots.txt From CMS Content — the origin-wide half of the same control surface
- Flattening Redirect Chains in Edge Middleware — the other thing middleware is commonly asked to do, and how the two interact