Controlling Indexing with Robots Meta in SPAs
Keeping a page out of the index sounds trivial — add noindex — but in a client-rendered app the timing of when that directive appears decides whether a crawler ever sees it. This guide shows how to serve a directive a crawler reads reliably, and sits within sitemaps and indexing control for SPAs.
Problem Statement
The robots meta directive tells a crawler whether it may index a page and follow its links. In a SPA it is tempting to set that directive the same way you set the title — from a router guard or an effect that runs after the component mounts. That works for a browser, but a crawler renders on its own schedule, and if the initial HTML already contained indexable content, the crawler can snapshot the indexable version before your script injects noindex. The directive arrives, but too late to matter. This is the render-then-noindex trap: the page renders as indexable, and only afterward asks not to be indexed.
The trap is a timing problem, so the fix is a timing fix — put the directive in the response the crawler renders, not in code that runs after the render snapshot.
Step-by-step fix
-
Decide the directive per route, on the server or edge. Map each route pattern to its indexing intent in one place, so the value is computed before any HTML is generated. Staging, account, search-result, and filtered routes typically get
noindex.// robots-policy.ts export type RobotsDirective = 'index, follow' | 'noindex, follow' | 'noindex, nofollow'; const NOINDEX_PATTERNS: RegExp[] = [ /^\/account(\/|$)/, /^\/search(\/|$)/, // internal search results /^\/preview\//, /\?.*\bsessionid=/, // session-scoped variants ]; export function directiveFor(pathname: string, search: string): RobotsDirective { const url = pathname + search; return NOINDEX_PATTERNS.some((re) => re.test(url)) ? 'noindex, follow' : 'index, follow'; } -
Prefer the
X-Robots-Tagheader when you control responses. Because it is an HTTP header, it applies before the crawler parses a single byte of body, so render timing is irrelevant. This is the most robust option at an edge worker or origin server.// edge-middleware.ts — runs at the CDN edge before the app shell is served import { directiveFor } from './robots-policy'; export function onRequest(request: Request): Response | void { const { pathname, search } = new URL(request.url); const directive = directiveFor(pathname, search); if (directive.startsWith('noindex')) { // Attach the header to the shell response for this route return new Response(appShellHtml, { headers: { 'Content-Type': 'text/html; charset=utf-8', 'X-Robots-Tag': directive, }, }); } // otherwise fall through to the normal indexable response } -
Otherwise, render the meta tag into the initial HTML. On a prerendered or static host where you can shape the HTML per route but not the headers, write the directive into the
headof the served document so it is present before render.<!-- Prerendered head for /account/settings — present in the initial response --> <meta name="robots" content="noindex, follow"> -
Keep the client from downgrading the directive. If the app also manages the head at runtime, make sure it does not overwrite a server-set
noindexwith a defaultindexon hydration. Read the existing value and preserve a stricter one.// preserve-robots.ts — run during head setup, do not clobber a server noindex function setRobots(intended: string) { const el = document.querySelector('meta[name="robots"]'); const current = el?.getAttribute('content') ?? ''; // Never relax a server-provided noindex during hydration if (current.includes('noindex') && !intended.includes('noindex')) return; if (!el) { const meta = document.createElement('meta'); meta.name = 'robots'; meta.content = intended; document.head.appendChild(meta); } else { el.setAttribute('content', intended); } } -
Do not disallow the route in
robots.txt. A crawler that is blocked from fetching the URL can never read itsnoindex, so blocking and excluding are mutually exclusive. To remove a page from the index, keep it crawlable and let the directive do the work.
Validation
Verify the directive reaches the crawler in the initial response, not just the hydrated DOM:
-
Check the header and raw HTML directly.
curl -sI https://example.com/account/settingsshould show theX-Robots-Tag, andcurl -sof the same URL should show the meta tag in the initial body — before any JavaScript runs. -
Use URL Inspection in Search Console. The Indexing allowed? line and the rendered HTML confirm what the crawler actually saw; a page you excluded should report indexing as disallowed by a
noindexdirective. -
Assert the policy in CI so a routing change cannot silently make a private route indexable.
// robots-policy.test.ts import { directiveFor } from './robots-policy'; test('private routes resolve to noindex, public ones to index', () => { console.assert(directiveFor('/account/settings', '') === 'noindex, follow', 'account must be noindex'); console.assert(directiveFor('/blog/hello', '') === 'index, follow', 'blog must be indexable'); }); -
Watch the Page indexing report for pages moving to Excluded by noindex tag after deploy, confirming the directive took effect.
Common Pitfalls
- Setting
noindexin auseEffector route guard only. The directive runs after render and can miss the snapshot — the core render-then-noindex trap. Serve it in the initial response. - Blocking in
robots.txtand expecting deindexing. The crawler never fetches the page, so it never sees thenoindex, and the URL can still appear in results from external links. - Client head manager resetting robots on hydration. A default
indexwritten during hydration can undo a servernoindex; preserve the stricter value, a pattern related to avoiding metadata hydration pitfalls. - Leaving
noindexURLs in the sitemap. A page you exclude should not be advertised for discovery; keep the two in sync with dynamic XML sitemap generation. - Conflicting directives across header and meta. If the header says
indexand the meta saysnoindex, the most restrictive wins, but the ambiguity is a maintenance hazard — pick one source per route.
Frequently Asked Questions
Why does Google index a SPA page that has a noindex tag?
Because the noindex was added by JavaScript after the page rendered its indexable content. A crawler can capture the indexable state before the client script runs, and if the page also shipped indexable HTML first, the late directive is ignored. Send the directive in the initial response instead.
Should I use the robots meta tag or the X-Robots-Tag header?
Use the X-Robots-Tag HTTP header when you can set per-route response headers at the origin or edge, because it applies before any HTML is parsed and cannot be affected by render timing. Use a server-set robots meta tag when you only control the HTML, such as a prerendered or static host.
Can I block a page from indexing with robots.txt instead?
No. Disallowing a URL in robots.txt stops the crawler from fetching it, which means it never sees a noindex directive on that page and may still index the URL from external links. To remove a page from the index, allow crawling and serve a noindex directive the crawler can read.
Related
- Sitemaps & Indexing Control for SPAs — how permission fits alongside discovery and notification.
- Generating Dynamic XML Sitemaps for SPAs — keep excluded URLs out of the inventory.
- Avoiding Metadata Hydration Pitfalls — stop hydration from overwriting a server-set directive.
← Back to Sitemaps & Indexing Control for SPAs