- app plugin seo-route-robots: a page whose meta sets requiresAuth: true gets robots noindex, nofollow, a page with seo.robots gets its value, a path under seo.noindexRoutes gets noindex, nofollow; meta tag above the CMS head entry and X-Robots-Tag during SSR, never on staging - requiresAuth and seo added to Nuxt's page meta extraction keys - playground /konto, /szkic and /prywatne with their smoke lines
vue-seo-layer
The Nuxt half of the SummerCMS SEO plugin (sm-seo-plugin, plugin ID golem15.seo). For every page it asks the plugin for the redirect decision and the effective SEO, enforces the decision, and sets the head from the answer, including hreflang and one JSON-LD @graph. It also serves the site's root files from the backend (verification files, and with SEO v0.2.0 robots.txt, the sitemaps, llms.txt and the IndexNow key), reports 404s to the plugin's 404 log, and owns the shared site cache purge route.
Install
"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"
// nuxt.config.ts of the app
export default defineNuxtConfig({
extends: ['vue-seo-layer'],
})
Pin a tag; the features marked "v0.2.0" below need that tag and, where they read new answer keys, the SEO plugin's v0.2.0 backend. With an older backend the layer behaves as v0.1.1.
The layer's only module is its own local one (lib/seo-module.ts); it pulls in no package and no @nuxtjs/i18n. It works next to @nuxtjs/seo, the Journal layer or an app's own useSeoMeta; its options share the seo config key with nuxt-seo-utils and use none of its names.
What it does
- Resolve. Every page request is resolved with
GET <apiBase>/resolve?url=<path and query>before Nuxt renders it, in a Nitro middleware, so the render uses the same answer and makes no second call. Answers are cached (v0.2.0): path and query as the key,seo.resolveCacheTtlseconds (default 60), at most 1000 entries, and concurrent requests for one path share one call; the site cache purge empties the cache. In the browser a global route middleware resolves each client-side navigation with the public base (the server's answer travels in the payload, so hydration fetches nothing). Nuxt's own paths (/_…,/api/…) and asset files are never resolved by the middleware (files with an extension inresolveFiles, defaultpdf, are, so an old PDF address can redirect); the route middleware still resolves a page under a dotted path. A call that fails, times out (timeoutMs) or answers anything unexpected is ignored: the page renders with its own head (fail open). - Redirects.
redirectanswers 301 or 302 withLocation, before the page and before Nitro's route cache, so a redirect added for answr-cached page applies on the next request after the resolve cache entry expires or the site cache is purged. A target on the site's own origin (the origin of the answer'surl) becomes a path, so a staging or preview host keeps the visitor even when the backend's site URL names production; a target on another host stays absolute. Nothing from the request (Host, X-Forwarded-Host, path, query) goes into the Location. The redirect wins over the route, so an old address no page matches still redirects. - 410.
goneanswers 410 with the app's error page, also on a route that would render. - 404 for a bad page number (v0.2.0). A
not_foundanswer withpagination.invalid(?page=abc) is a 404. Every othernot_foundis left to Nuxt's routing and the pages. - Head. An
okanswer sets the title (final; the plugin applies its templates, so the app'stitleTemplateis replaced by%s),description,canonical,robots,og:title,og:description,og:image,og:url,twitter:card,twitter:siteand the verification meta tags, and from v0.2.0 answers<html lang>(html_lang),og:locale, oneog:locale:alternateper value, one<link rel="alternate" hreflang>per entry ofalternates, and exactly one<script type="application/ld+json">holding one@graph. The entry hastagPriority: 'critical', so its tags win over the same tags set by a page, a layer or a module; a value the answer leaves empty adds no tag, and the page's own stays. A canonical,og:url,og:imageor alternate URL the plugin answers as a path (a backend withoutgolem15.seo.site_urlorapp.url) is made absolute on the request's origin; set the site URL on the backend so it names the public origin.not_foundanswers leave the head to the page. One app plugin (seo-head, throughuseCmsSeo) applies the head on every route; pages and other layers do not set CMS values themselves. - One JSON-LD graph (v0.2.0). The graph holds the CMS nodes (the answer's
jsonld) plus the nodes pages and layers add withuseSchemaNode(node), merged by@id: a node whose@idthe CMS already has fills in only the keys the CMS node lacks; a new@id(or none) is appended. No node at all, no script. unhead escapes<inside the JSON, so a value cannot end the script element. - Robots and staging. A
staginganswer (the backend outsideSUMMER_ENV=production) makes every pagenoindex, nofollow, known or not. TheX-Robots-Tagresponse header follows the same value, or the page'sx_robots_tag; a redirect carries none. - 404 log. Each page view answered 404 (a GET that accepts
text/html, from an unknown route or a page that throws a 404) is reported once withPOST <apiBase>/notfound,{"url", "referer"}andAuthorization: Bearer <notfoundToken>, after the response, so it never delays it. Without a token nothing is reported. - Root files. With a backend that answers
GET <apiBase>/site(v0.2.0),/robots.txt,/sitemap.xml,/llms.txtand/sitemap-<name>.xml(served from the backend's/_sitemap/files/) are proxied tobackendBase, and so is each one-segment.html,.txtor.xmlfile the site answer lists inroot_files: a verification file (<apiBase>/verification/<name>) or the IndexNow key file (/_sitemap/indexnow/<key>.txt). The backend's status, content type andX-Robots-Tagare passed on, withX-Content-Type-Options: nosniff; a backend that does not answer or answers 5xx gives 503 withRetry-After: 60, so crawlers retry instead of reading a missing robots.txt as "everything allowed". Any other path passes through to the app. The site answer is cached for 300 seconds and cleared by the purge; while the backend is down the last one seen is used. With a backend whose/siteanswers 404 (before v0.2.0, or no SEO plugin), or one not reached since the server started, robots.txt and the sitemaps are not proxied (route them to the backend in the site's proxy) and verification files keep the v0.1.1 lookup: a request for a single-segment.html,.txtor.xmlfile (/google0123.html,/BingSiteAuth.xml) is looked up underGET <apiBase>/verification/<name>and served withX-Content-Type-Options: nosniff, a sandboxing CSP andX-Robots-Tag: noindexwhen the plugin has it; otherwise it falls through to the app's routes.robots.txt,sitemap.xml,sitemap.xsl,rss.xml,feed.xml,atom.xmlandllms.txtare never looked up that way.rootFiles: falseturns both off. - Site cache purge (v0.2.0).
POST /api/__site__/cache/purge(moved here from vue-journal-layer, vue-menu-layer and vue-faq-layer, same contract) removes every Nitro route-cache entry and empties the layer's resolve and site caches, so an admin save shows on the next page view. The backend calls it on loopback after the save commits, withAuthorization: Bearer <cachePurge.token>and{"kind","slug"}; the answer is{"purged": <n>}. It answers 404 without a token of at least 32 characters and for any proxied request (X-Forwarded-For, X-Real-IP or Forwarded) unlesscachePurge.allowProxiedis true, and 401 for a missing or wrong bearer./api/__site__/**is never route-cached. While another layer still ships its own copy of the route and that copy answers,server/plugins/seo-cache-purge.tsempties the SEO caches after any 200 purge (also after the journal's deprecated/api/__journal__/cache/purge). - Visitor address. The server calls forward the visitor's
X-Forwarded-Forplus the address of the peer that reached Nuxt, also on route-cached (swr) renders, so the plugin's per-IP throttles (seo-resolve,seo-notfound, 600 a minute) keep one bucket per visitor. List the Nuxt server's address in the backend'shttp.trusted_proxies.
Options
Module options under the seo key of the app's nuxt.config.ts (v0.2.0). They are copied to runtimeConfig.public.seo, so each can also be set at run time (NUXT_PUBLIC_SEO_<NAME>).
| Option | Default | Meaning |
|---|---|---|
cms |
true |
false turns every CMS call off (no resolve, no head from the CMS, no redirects, no root files, no 404 reports), for a site without the SEO plugin. An alias of public.seo.enabled, which keeps working: either one false switches the layer off. |
breadcrumbs |
true |
Breadcrumbs from the CMS (breadcrumbs of the answer). |
links.newTabHint |
true |
A visually hidden "(opens in a new tab)" inside external links while the CMS's links_new_tab is on; copied to runtimeConfig.public.seo.links.newTabHint for the body-link rules. |
rootFiles |
true |
Serve robots.txt, the sitemaps, llms.txt and the root files from the backend (see Root files). |
resolveFiles |
['pdf'] |
File extensions the Nitro middleware still resolves. |
Configuration
| Runtime config | Env | Default | Meaning |
|---|---|---|---|
seo.apiBase |
NUXT_SEO_API_BASE |
http://127.0.0.1:8096/_seo/api/v1 |
Server-only base of the plugin's API for SSR calls, the site answer, verification files and 404 reports. |
seo.backendBase |
NUXT_SEO_BACKEND_BASE |
"" |
Server-only origin of the Go binary for robots.txt, llms.txt and the sitemap and IndexNow files (v0.2.0). Empty: apiBase without /_seo/api/v1. |
seo.notfoundToken |
NUXT_SEO_NOTFOUND_TOKEN |
"" |
The backend's golem15.seo.notfound_token. Empty: 404s are not reported. Server-only; not under NUXT_SITE_*, which nuxt-site-config publishes in every page's payload. |
seo.routeReportToken |
NUXT_SEO_ROUTE_REPORT_TOKEN |
"" |
Bearer token of the route report to the plugin's page registry (v0.2.0). Empty: notfoundToken. |
seo.timeoutMs |
NUXT_SEO_TIMEOUT_MS |
1500 |
How long a call may take before the page renders without it. |
seo.resolveCacheTtl |
NUXT_SEO_RESOLVE_CACHE_TTL |
60 |
Seconds a resolve answer stays cached (v0.2.0). 0 turns the cache off; concurrent requests for one path still share one call. |
cachePurge.token |
NUXT_CACHE_PURGE_TOKEN |
"" |
Bearer token of the site cache purge route, the backend's site_cache.purge_token. Shorter than 32 characters: the route answers 404. |
cachePurge.allowProxied |
NUXT_CACHE_PURGE_ALLOW_PROXIED |
false |
true when the backend reaches the site through a TLS-terminating proxy; the bearer token is then the only gate. |
siteCache.purgeToken, siteCache.allowProxied |
NUXT_SITE_CACHE_PURGE_TOKEN, NUXT_SITE_CACHE_ALLOW_PROXIED |
Deprecated names of the two above, read when the new ones are unset; the layer strips them from nuxt-site-config's site config and logs one warning. Rotate a token that was ever set under the old name. | |
public.seo.apiBase |
NUXT_PUBLIC_SEO_API_BASE |
/_seo/api/v1 |
Same-origin base for client-side navigations. The site's proxy must route GET <base>/resolve to the backend. Set it to /api/__seo__ to resolve through the Nuxt server (GET /api/__seo__/resolve?url=, the server's cache, no proxy rule needed). |
public.seo.enabled |
NUXT_PUBLIC_SEO_ENABLED |
true |
false turns the layer off: no calls, no head, no redirects, no root files, no reports. Same as the option cms: false. |
Resolve answer
The layer reads the answer of GET /_seo/api/v1/resolve (app/utils/types.ts): action (ok, not_found, redirect, gone), status, location, url, locale, staging, and on ok answers seo with title, description, canonical, robots, x_robots_tag, og (title, description, image, url, and from v0.2.0 locale, locale_alternates), twitter (card, site) and verification ([{name, content}]). SEO plugin v0.2.0 adds html_lang, alternates ([{hreflang, url}], absolute, the page itself and x-default included), jsonld (one {"@context", "@graph"} object or null), breadcrumbs ([{name, url, path}]), pagination ({form, param, segment, page, invalid}, only when the request carries a page), outbound, autolinks and autolink_max. Absent keys are treated as empty. The site answer (GET /_seo/api/v1/site) carries site_url, default_locale, locales, unprefixed_locale, links.new_tab, html_sitemap, routes, root_files, llms, pagination and home_crumb.
Composables and utils
useSeoAnswer(): the resolve answer of the current page (SeoResolveAnswer | null), shared state.resolveSeo(url): one resolve call with the server or public base;nullon any failure. During SSR it takes the answer the Nitro middleware resolved for the request.useCmsSeo(answer): the head from an answer ref (the layer's plugin calls it; apps do not).useSchemaNode(node): adds a schema.org node (an object, a ref or a getter) to the page's one@graphfor as long as the calling component lives (v0.2.0).useSeoLabels(): the label dictionary of the page's content locale (v0.2.0, see Labels and tokens).mergeGraph(cmsJsonld, nodes),isPagePath(path, resolveFiles),pickLabels(locale, htmlLang, overrides),formatSeoLabel(template, values),seoEnabled(publicSeo): pure helpers (v0.2.0).seoHeadInput,seoAbsolute,seoRedirectTarget,seoRobots,seoXRobotsTag,seoResolvableUrl,seoAnswer,seoVerificationName: the pure helpers behind them (app/utils/seo.ts), auto-imported.
Labels and tokens
useSeoLabels() returns the strings of the layer's public components in the page's content locale: the answer's locale, then the primary subtag of html_lang, then en. The layer ships en and pl (app/utils/labels.ts); an app overrides keys or adds a locale in its app.config.ts, and component props override both:
export default defineAppConfig({
seo: { labels: { pl: { sitemap: { title: 'Spis stron' } }, de: { pagination: { next: 'Nächste Seite' } } } },
})
app/assets/css/seo-tokens.css defines the tokens (--seo-space-1 to --seo-space-5, --seo-target-size, --seo-sitemap-column-width, --seo-color-muted, --seo-color-accent, --seo-color-on-accent, --seo-color-border, --seo-font-size-small, --seo-breadcrumbs-separator) and seo-base.css the base styles, which target only .seo-* classes (.seo-visually-hidden and the focus ring of layer links). Both load before the app's CSS; override a token on :root:root in the app's own CSS.
Development
pnpm install
pnpm test # node --test on the helpers, the cache, the purge and the labels
pnpm build:playground
pnpm smoke:ssr # the playground against testing/fixture-api.mjs, six runs
pnpm purge:check # testing/purge-check.mjs and purge-token-check.mjs
testing/fixture-api.mjs stands in for the plugin's public API and the Go binary's root files: resolve answers from testing/fixtures/seo/resolve.json and testing/fixtures/resolve.json, the site answer from testing/fixtures/seo/site.json, the 404 report with FIXTURE_NOTFOUND_TOKEN, verification files, root files and the IndexNow key under testing/fixtures/seo/. testing/smoke-ssr.mjs checks status, body, content type and headers per line (contains=, notcontains=, count=TEXT:N, type=, header=Name: value, noheader=, reqheader=, each repeatable), report and noreport lines for the 404 reports, and resolves PATH N and apicalls N lines for the calls the API saw; --no-site makes the API answer /site with 404, as an older backend does. pnpm smoke:ssr runs testing/expect.txt (the v0.1.1 behaviour, against a backend without /site), testing/expect-seo.txt, testing/expect-seo-off.txt (with NUXT_PUBLIC_SEO_CMS=false and with NUXT_PUBLIC_SEO_ENABLED=false), testing/expect-seo-down.txt (the backend unreachable) and testing/expect-seo-notoken.txt. testing/purge-check.mjs proves on the route-cached playground (/blog/** swr) that a purge shows a changed CMS answer at once and that the route refuses unauthenticated, wrong-token and proxied requests; testing/purge-token-check.mjs that the purge token never reaches a page or the log.