diff --git a/.playground/app/pages/konto.vue b/.playground/app/pages/konto.vue
new file mode 100644
index 0000000..b799cbd
--- /dev/null
+++ b/.playground/app/pages/konto.vue
@@ -0,0 +1,11 @@
+
+
+
+ Konto
+
diff --git a/.playground/app/pages/prywatne/index.vue b/.playground/app/pages/prywatne/index.vue
new file mode 100644
index 0000000..3a443b7
--- /dev/null
+++ b/.playground/app/pages/prywatne/index.vue
@@ -0,0 +1,9 @@
+
+
+
+ Prywatne
+
diff --git a/.playground/app/pages/szkic.vue b/.playground/app/pages/szkic.vue
new file mode 100644
index 0000000..f928ce6
--- /dev/null
+++ b/.playground/app/pages/szkic.vue
@@ -0,0 +1,9 @@
+
+
+
+ Szkic
+
diff --git a/.playground/nuxt.config.ts b/.playground/nuxt.config.ts
index 297f9ef..4ced7c0 100644
--- a/.playground/nuxt.config.ts
+++ b/.playground/nuxt.config.ts
@@ -4,7 +4,7 @@
// testing/smoke-ssr.mjs builds it against the fixture API. The /blog pages
// are route-cached (swr) as on a production site, so the smoke and
// testing/purge-check.mjs see redirects, 410 and purges apply to cached
-// pages.
+// pages. /prywatne/** is declared noindex.
export default defineNuxtConfig({
extends: ['..'],
compatibilityDate: '2025-07-15',
@@ -15,4 +15,7 @@ export default defineNuxtConfig({
// (testing/expect.txt, /relative) needs uncached pages.
'/blog/**': { swr: 300 },
},
+ seo: {
+ noindexRoutes: ['/prywatne/**'],
+ },
})
diff --git a/app/plugins/seo-route-robots.ts b/app/plugins/seo-route-robots.ts
new file mode 100644
index 0000000..0bca8d0
--- /dev/null
+++ b/app/plugins/seo-route-robots.ts
@@ -0,0 +1,50 @@
+// Noindex for the routes an app declares (D-23, D-21), without page code:
+//
+// - a page whose meta sets requiresAuth: true (a signed-in or auth page)
+// gets robots "noindex, nofollow";
+// - a page whose meta sets seo: { robots: '...' } gets that value;
+// - a path under seo.noindexRoutes gets "noindex, nofollow" (its route
+// rule already sends the header; this adds the meta tag).
+//
+// The value goes into a robots meta tag that wins over the CMS's (a lower
+// tagPriority than the head plugin's 'critical') and, during SSR, into the
+// X-Robots-Tag header once the navigation is done, after the route
+// middleware. Every other page keeps the CMS robots; on staging the CMS's
+// noindex, nofollow stays. Works with the layer off too: it reads no CMS.
+
+import { isNoindex } from '../../lib/guard.ts'
+import { pageMetaRobots } from '../../lib/routes.ts'
+
+/** Wins over the CMS head entry (tagPriority 'critical', -8). */
+const ROUTE_ROBOTS_PRIORITY = -10
+
+export default defineNuxtPlugin((nuxtApp) => {
+ const config = useRuntimeConfig()
+ const declared = (config.public.seo as { noindexRoutes?: unknown } | undefined)?.noindexRoutes
+ const noindexRoutes = Array.isArray(declared) ? declared.filter((p): p is string => typeof p === 'string') : []
+ const router = useRouter()
+ const answer = useSeoAnswer()
+
+ const robotsFor = (route: { path: string; meta: Record }): string | null => {
+ if (answer.value?.staging === true) return null
+ const own = pageMetaRobots(route.meta)
+ if (own) return own
+ return isNoindex(route.path, noindexRoutes) ? 'noindex, nofollow' : null
+ }
+
+ useHead(
+ computed(() => {
+ const robots = robotsFor(router.currentRoute.value)
+ return robots ? { meta: [{ name: 'robots', content: robots }] } : {}
+ }),
+ { tagPriority: ROUTE_ROBOTS_PRIORITY },
+ )
+
+ if (import.meta.server) {
+ router.afterEach((to, _from, failure) => {
+ if (failure) return
+ const robots = robotsFor(to)
+ if (robots) nuxtApp.runWithContext(() => (useResponseHeader('X-Robots-Tag').value = robots))
+ })
+ }
+})
diff --git a/lib/routes.ts b/lib/routes.ts
new file mode 100644
index 0000000..a530084
--- /dev/null
+++ b/lib/routes.ts
@@ -0,0 +1,25 @@
+// The routes the layer tells the CMS about (D-33) and compares with it at
+// server start (D-20c). Built at build time from the resolved pages, whose
+// meta carries requiresAuth and seo (the module adds both to Nuxt's page
+// meta extraction keys), and written into the server template #seo-routes
+// for the route report and the drift check. Pure TypeScript, so
+// `node --test` drives it.
+
+/** True when a robots value keeps the page out of the index. */
+export const robotsNoindex = (robots: unknown) => typeof robots === 'string' && /(^|[\s,])(noindex|none)($|[\s,])/i.test(robots)
+
+/**
+ * The robots value a page asks for through its meta: seo.robots when set,
+ * else 'noindex, nofollow' for a page with requiresAuth: true (a signed-in
+ * or auth page, D-23), else null (the CMS decides).
+ */
+export function pageMetaRobots(meta: Record | undefined | null): string | null {
+ if (!meta || typeof meta !== 'object') return null
+ const seo = meta.seo
+ if (seo && typeof seo === 'object') {
+ const robots = (seo as { robots?: unknown }).robots
+ if (typeof robots === 'string' && robots.trim() !== '') return robots.trim()
+ }
+ if (meta.requiresAuth === true) return 'noindex, nofollow'
+ return null
+}
diff --git a/lib/seo-module.ts b/lib/seo-module.ts
index b8faee1..09ba357 100644
--- a/lib/seo-module.ts
+++ b/lib/seo-module.ts
@@ -73,6 +73,13 @@ export default defineNuxtModule({
const ssr = nuxt.options.ssr !== false
const inlineRouteRules = nuxt.options.experimental?.inlineRouteRules === true
+ // requiresAuth and seo are read from definePageMeta at build time, so
+ // the route report leaves signed-in and noindex pages out (D-23).
+ const extract = new Set(nuxt.options.experimental.extraPageMetaExtractionKeys ?? [])
+ extract.add('requiresAuth')
+ extract.add('seo')
+ nuxt.options.experimental.extraPageMetaExtractionKeys = [...extract]
+
// Build guard (D-70). Nitro's config hook runs once every layer and
// module has merged its rules into it; there the declared noindex
// routes get their X-Robots-Tag rule. The pages are resolved after
diff --git a/testing/expect-seo.txt b/testing/expect-seo.txt
index 2ebdf93..58f3a5a 100644
--- a/testing/expect-seo.txt
+++ b/testing/expect-seo.txt
@@ -66,3 +66,12 @@ noreport /stary
# Labels in the page's content locale (pl answer: the pl dictionary).
200 /blog/lwia-grzywa contains=Ścieżka nawigacji
+
+# Declared noindex without page code: a page whose meta requires sign-in,
+# a page with its own seo.robots and a path under seo.noindexRoutes get the
+# meta tag and the header; an ordinary page keeps the CMS robots.
+200 /konto contains= count=name="robots":1 header=X-Robots-Tag: noindex, nofollow
+200 /szkic contains= header=X-Robots-Tag: noindex, follow
+200 /prywatne contains= header=X-Robots-Tag: noindex, nofollow
+200 /plain noheader=X-Robots-Tag count=name="robots":0
+200 /blog/lwia-grzywa contains=
diff --git a/tests/routes.test.ts b/tests/routes.test.ts
new file mode 100644
index 0000000..ce5ada2
--- /dev/null
+++ b/tests/routes.test.ts
@@ -0,0 +1,19 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { pageMetaRobots, robotsNoindex } from '../lib/routes.ts'
+
+test('pageMetaRobots: seo.robots, then requiresAuth, else the CMS decides', () => {
+ assert.equal(pageMetaRobots({ requiresAuth: true }), 'noindex, nofollow')
+ assert.equal(pageMetaRobots({ requiresAuth: true, seo: { robots: 'noindex, follow' } }), 'noindex, follow')
+ assert.equal(pageMetaRobots({ seo: { robots: ' index, follow ' } }), 'index, follow')
+ assert.equal(pageMetaRobots({ requiresAuth: false }), null)
+ assert.equal(pageMetaRobots({ seo: { breadcrumbs: false } }), null)
+ assert.equal(pageMetaRobots(undefined), null)
+})
+
+test('robotsNoindex: noindex and none keep a page out', () => {
+ assert.equal(robotsNoindex('noindex, follow'), true)
+ assert.equal(robotsNoindex('none'), true)
+ assert.equal(robotsNoindex('index, follow'), false)
+ assert.equal(robotsNoindex(null), false)
+})