Jakub Zych 160b53b2f5 feat: CMS head and one JSON-LD graph on every server-rendered page (tracer)
- Nitro middleware resolves page requests once and hands the answer to the
  render (event.context.seo); the route middleware reuses it
- useCmsSeo: html lang, hreflang links, og:locale and exactly one JSON-LD
  script with one @graph (CMS nodes plus useSchemaNode nodes, merged by @id)
- local module (configKey seo): cms (alias of public.seo.enabled),
  breadcrumbs, links.newTabHint, rootFiles, resolveFiles
- GET /api/__seo__/resolve for client navigation through the Nuxt server
- smoke harness: count=, exact header=, noheader=, reqheader=, repeated
  options, resolves/apicalls/noreport lines, --no-site; v0.1.1 suite runs
  unchanged against a backend without /site
2026-11-04 09:15:26 +01:00

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. It also reports 404s to the plugin's 404 log and serves the verification files at the site root.

// nuxt.config.ts of the app
export default defineNuxtConfig({
  extends: ['vue-seo-layer'],
})
"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.1.1"

The layer declares no Nuxt modules and works next to @nuxtjs/seo, the Journal layer or an app's own useSeoMeta.

What it does

  • Resolve. A global route middleware calls GET <apiBase>/resolve?url=<path and query> before the page runs, on the server for the first request and in the browser on each client-side navigation (the server's answer travels in the payload, so hydration fetches nothing). Nuxt's own paths (/_…, /api/…) are never resolved. A call that fails, times out (timeoutMs) or answers anything unexpected is ignored: the page renders with its own head.
  • Redirects. redirect answers 301 or 302 with Location. A target on the site's own origin (the origin of the answer's url) 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. The redirect wins over the route, so an old address no page matches still redirects.
  • 410. gone answers 410 with the app's error page, also on a route that would render.
  • Head. An ok answer sets the title (final; the plugin applies its templates, so the app's titleTemplate is replaced by %s), description, canonical, robots, og:title, og:description, og:image, og:url, twitter:card, twitter:site and the verification meta tags. The entry has tagPriority: '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 or og:image the plugin answers as a path (a backend without golem15.seo.site_url or app.url) is made absolute on the request's origin; set the site URL on the backend so it names the public origin. not_found answers (pages the plugin does not know: listings, search, author pages, static pages without a URL rule) leave the head to the page.
  • Robots and staging. A staging answer (the backend outside SUMMER_ENV=production) makes every page noindex, nofollow, known or not. The X-Robots-Tag response header follows the same value.
  • 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 with POST <apiBase>/notfound, {"url", "referer"} and Authorization: Bearer <notfoundToken>, after the response. Without a token nothing is reported.
  • Verification files. A request for a single-segment .html, .txt or .xml file (/google0123.html, /BingSiteAuth.xml) is looked up under GET <apiBase>/verification/<name> and served with X-Content-Type-Options: nosniff, a sandboxing CSP and X-Robots-Tag: noindex when the plugin has it; otherwise it falls through to the app's routes. robots.txt, sitemap.xml, sitemap.xsl, rss.xml, feed.xml, atom.xml and llms.txt are never looked up.
  • Visitor address. The server calls forward the visitor's X-Forwarded-For plus 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's http.trusted_proxies.

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 and 404 reports.
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.timeoutMs NUXT_SEO_TIMEOUT_MS 1500 How long a call may take before the page renders without it.
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.
public.seo.enabled NUXT_PUBLIC_SEO_ENABLED true false turns the layer off: no calls, no head, no redirects, no reports.

The plugin serves /robots.txt itself; route that path to the backend (and turn off another robots.txt route, such as the one of @nuxtjs/robots).

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; null on any failure.
  • seoHeadInput, seoAbsolute, seoRedirectTarget, seoRobots, seoXRobotsTag, seoResolvableUrl, seoAnswer, seoVerificationName: the pure helpers behind them (app/utils/seo.ts), auto-imported.

Development

pnpm install
pnpm test                 # node --test on the helpers
pnpm build:playground
pnpm smoke:ssr            # the playground against testing/fixture-api.mjs and testing/expect.txt

testing/fixture-api.mjs stands in for the plugin's public API (resolve answers from testing/fixtures/resolve.json, the 404 report with FIXTURE_NOTFOUND_TOKEN, verification files from testing/fixtures/verification/). testing/smoke-ssr.mjs checks status, body, content type and headers (header=Name: text) per line, and report PATH lines require a 404 report for PATH.

Description
No description provided
Readme 332 KiB
Languages
TypeScript 77.2%
JavaScript 15%
Vue 6.3%
CSS 1.5%