From 00514a619351b5f05352ea35168d1ae721fb18ce Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 13 Oct 2026 12:20:08 +1100 Subject: [PATCH] fix(route-rules): keep an explicit X-Robots-Tag route rule header The middleware replaced a route rule's own X-Robots-Tag header with the computed robots rule, so the header and the robots meta tag disagreed. The header now becomes the route's robots rule at build time. --- src/header-rules.ts | 27 ++++++++++++++++++ src/module.ts | 10 +++++-- test/e2e/headersRouteRuleExplicit.test.ts | 28 +++++++++++++++++++ .../headersRouteRuleExplicitStatic.test.ts | 25 +++++++++++++++++ test/fixtures/nuxt5/nuxt.config.ts | 3 ++ test/fixtures/nuxt5/server/api/compat.get.ts | 1 + test/fixtures/nuxt5/test.mjs | 1 + 7 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 test/e2e/headersRouteRuleExplicit.test.ts create mode 100644 test/e2e/headersRouteRuleExplicitStatic.test.ts diff --git a/src/header-rules.ts b/src/header-rules.ts index a5900b84..09e6b893 100644 --- a/src/header-rules.ts +++ b/src/header-rules.ts @@ -14,6 +14,33 @@ export interface RobotsHeaderRulesInput { buildAssetsDir: string } +/** + * Find the `X-Robots-Tag` header in a route rule's headers, in any casing. + */ +export function findRobotsHeader(headers: Record | undefined): { key: string, value: string } | undefined { + for (const [key, value] of Object.entries(headers || {})) { + if (key.toLowerCase() === 'x-robots-tag' && typeof value === 'string' && value.trim()) + return { key, value } + } +} + +/** + * Use the `X-Robots-Tag` header of a route rule as its robots rule. + * + * The runtime sends the robots rule as the header and the meta tag. Without this, it would + * replace the configured header and the meta tag would disagree with it. + * A route rule that sets `robots` keeps it. + */ +export function applyRobotsHeaderRouteRules(routeRules: Record }) | undefined>): void { + for (const rules of Object.values(routeRules)) { + if (!rules || typeof rules.robots !== 'undefined') + continue + const header = findRobotsHeader(rules.headers) + if (header) + rules.robots = header.value + } +} + /** * Resolve the `X-Robots-Tag` header for each route rule. * diff --git a/src/module.ts b/src/module.ts index 0ea1b93d..c167d830 100644 --- a/src/module.ts +++ b/src/module.ts @@ -23,7 +23,7 @@ import { setupNitroRuntimeCompatibility, setupRuntimeAliases, useModuleLogger } import { withoutTrailingSlash, withTrailingSlash } from 'ufo' import { AiBots, AiTrainingBots, NonHelpfulBots } from './const' import { setupDevToolsUI } from './devtools' -import { resolveRobotsHeaderRules } from './header-rules' +import { applyRobotsHeaderRouteRules, findRobotsHeader, resolveRobotsHeaderRules } from './header-rules' import { mapPathForI18nPages, resolveI18nConfig, splitPathForI18nLocales } from './i18n' import { isNuxtGenerate, resolveContentProvider, resolveNitroPreset } from './kit' import { registerTypeTemplates } from './templates' @@ -529,6 +529,10 @@ export default defineNuxtModule({ // @ts-expect-error runtime type await nuxt.hooks.callHook('robots:config', config) + // a route rule that sets the header is that route's robots rule + applyRobotsHeaderRouteRules(nuxt.options.routeRules || {}) + applyRobotsHeaderRouteRules(nuxt.options.nitro.routeRules || {}) + // convert robot routeRules to header routeRules for static hosting if (config.header) { const noIndexPaths = [withoutTrailingSlash(nuxt.options.app.buildAssetsDir), `${nuxt.options.app.buildAssetsDir}**`] @@ -544,9 +548,11 @@ export default defineNuxtModule({ buildAssetsDir: nuxt.options.app.buildAssetsDir, }) for (const [route, rule] of Object.entries(headerRules)) { + // Reuse the configured header name. A static host would send two differently cased headers. + const headerName = findRobotsHeader(nuxt.options.routeRules?.[route]?.headers)?.key || 'X-Robots-Tag' extendRouteRules(route, { headers: { - 'X-Robots-Tag': rule, + [headerName]: rule, }, }, { override: true }) } diff --git a/test/e2e/headersRouteRuleExplicit.test.ts b/test/e2e/headersRouteRuleExplicit.test.ts new file mode 100644 index 00000000..947cf1b5 --- /dev/null +++ b/test/e2e/headersRouteRuleExplicit.test.ts @@ -0,0 +1,28 @@ +import { createResolver } from '@nuxt/kit' +import { $fetch, setup } from '@nuxt/test-utils' +import { describe, expect, it } from 'vitest' +import { fetchRobotsHeaders } from './utils/headers' + +const { resolve } = createResolver(import.meta.url) + +process.env.NODE_ENV = 'production' +await setup({ + rootDir: resolve('../fixtures/static-headers'), + build: true, + nuxtConfig: { + routeRules: { + '/': { headers: { 'x-robots-tag': 'noindex, nofollow' } }, + }, + }, +}) + +// The site is indexable. A route rule that sets the header itself must keep it. +describe('a route rule that sets the `X-Robots-Tag` header', () => { + it('sends the configured header', async () => { + expect(await fetchRobotsHeaders('/')).toEqual(['noindex, nofollow']) + }) + it('renders a robots meta tag with the same value', async () => { + const html = await $fetch('/') + expect(html).toMatch(/ { + it('sends the configured header once', () => { + expect(staticRobotsHeaders(readHeadersFile(), '/')).toEqual(['noindex, nofollow']) + }) +}) diff --git a/test/fixtures/nuxt5/nuxt.config.ts b/test/fixtures/nuxt5/nuxt.config.ts index 99a3a6ff..289b0d58 100644 --- a/test/fixtures/nuxt5/nuxt.config.ts +++ b/test/fixtures/nuxt5/nuxt.config.ts @@ -16,6 +16,9 @@ export default defineNuxtConfig({ '/private': { robots: false, }, + '/explicit-header': { + headers: { 'x-robots-tag': 'noindex, noarchive' }, + }, }, compatibilityDate: '2026-10-06', }) diff --git a/test/fixtures/nuxt5/server/api/compat.get.ts b/test/fixtures/nuxt5/server/api/compat.get.ts index 2c9a50c0..2e6309a7 100644 --- a/test/fixtures/nuxt5/server/api/compat.get.ts +++ b/test/fixtures/nuxt5/server/api/compat.get.ts @@ -11,6 +11,7 @@ const routeRule = { export default defineEventHandler(event => ({ trailingRule: getPathRobotConfig(event, { path: '/slash/', skipSiteIndexable: true }), lateRule: getPathRobotConfig(event, { path: '/late', skipSiteIndexable: true }), + explicitHeader: getPathRobotConfig(event, { path: '/explicit-header', skipSiteIndexable: true }), deepRobots: getDeepPathRobotConfig(event, { path: '/private' }), detectedBot: isBot(event), normalisedRouteRule: normaliseRobotsRouteRule(routeRule), diff --git a/test/fixtures/nuxt5/test.mjs b/test/fixtures/nuxt5/test.mjs index 088367e1..cc041a68 100644 --- a/test/fixtures/nuxt5/test.mjs +++ b/test/fixtures/nuxt5/test.mjs @@ -48,6 +48,7 @@ try { assert.deepEqual(context.normalisedRouteRule, { allow: false }) assert.equal(context.routeRule.robots, false) assert.equal(context.robots.indexable, false) + assert.equal(context.explicitHeader.rule, 'noindex, noarchive') const debugPath = await fetch(`${origin}/__robots__/debug-path.json?path=/private`).then(response => response.json()) assert.equal(debugPath.path, '/private') assert.equal(debugPath.indexable, false)