Skip to content

docs site: no metadataBase and no canonical link on any page #12234

Description

@os-zhuang

One-liner

Not one page on the site emits <link rel="canonical">, and the root layout sets no metadataBase. Every URL variant of a page — query strings, tracking parameters, the /en/ prefixed form — is a separate document to a search engine, with nothing declaring which one is real.

Measured

$ grep -rn "metadataBase\|canonical\|alternates" apps/docs --include=*.ts --include=*.tsx | grep -v node_modules
(no matches)
$ curl -s http://localhost:3999/docs/data-modeling/objects | grep -c 'rel="canonical"'
0

Without metadataBase, any relative URL handed to openGraph/alternates resolves against a build-time guess rather than the real origin — which is why it must land before, or with, the Open Graph card.

Expected

  • apps/docs/app/layout.tsx: metadataBase: new URL('https://objectstack.ai'), from the same shared origin constant the sitemap uses.
  • apps/docs/app/[lang]/docs/[[...slug]]/page.tsx generateMetadata: alternates: { canonical: page.url }.
  • Same for the homepage, the blog index and blog posts.

Acceptance

  • every page type emits exactly one <link rel="canonical">, absolute, on https://objectstack.ai
  • /docs/x?utm_source=y and /docs/x both canonicalise to /docs/x
  • the Next build no longer warns about a missing metadataBase

Source

Found in an SEO review of the docs site (apps/docs) run on 2026-08-25, measured against the local dev server and against production. The canonical origin is https://objectstack.ai — maintainer ruling recorded in #10659:

这个仓的文档站规范 URL 是 https://objectstack.ai

Activity

  1. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    Held for round 2 — serial, not blocked. This card imports the shared origin constant that #12232 (dispatched now) owns creating; dispatching both at once means two agents creating the same file. Known trap to carry into the dispatch: read what #12232 actually exported before writing the import, and do not re-create the constant if it is already on main by then. #12235 is queued behind this card because it edits the same generateMetadata block.

  2. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    ⚠️ Trap carried in from #12232's dev report — read before starting this card.

    apps/docs/app/page.tsx is a shadowed route: proxy.ts rewrites / to /en, so RootPage() never runs (measured: curl / returns 200 text/html, not the 307 that file would produce). Filed standalone as #12255.

    What it means here: the homepage's real metadata lives in apps/docs/app/[lang]/page.tsx. An agent that adds metadataBase or a canonical link to app/page.tsx will ship a green PR that changes nothing served. Verify against a rendered response, not against the file you edited.

    The epic does not depend on #12255 being fixed — it stays a standalone finding — but this card does depend on knowing about it.

  3. self-assigned this
    on Aug 25, 2026
  4. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 3 (epic PM for #12243)
    Session: session_f9f0958b-ab68-46cc-801c-216aa7ee2107
    Branch: claude/issue-12234-metadatabase-canonical
    Worktree: objectstack-issue-12234
    Domain: domain:devx
    File surface: apps/docs/app/layout.tsx, apps/docs/app/[lang]/page.tsx, apps/docs/app/[lang]/docs/[[...slug]]/page.tsx, apps/docs/app/[lang]/blog/[[...slug]]/page.tsx (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus — no path-derived mandate from scripts/pm/dispatch-gates.mjs --tier
    Clause-②: no
    Serial constraints cleared: apps/docs/lib/site.ts landed on main in PR #12253 (merged 16:31:53Z) — import it, do not re-create it. #12240 (JSON-LD) and #12242 (hero poster) are queued BEHIND this card: both touch files in this surface.

  5. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    Claim (os-dev, round 3): implementing this card.

    Session: session_f9f0958b-ab68-46cc-801c-216aa7ee2107 (os-dev subagent dispatched by the epic PM for #12243)
    Branch: claude/issue-12234-metadatabase-canonical (pushed empty at start as a write probe — green)
    Worktree: objectstack-issue-12234, base af58a6fbc4e51f3a1473c0c8a36cd6cbae3f5d0c
    File surface: apps/docs/app/layout.tsx, apps/docs/app/[lang]/page.tsx, apps/docs/app/[lang]/docs/[[...slug]]/page.tsx, apps/docs/app/[lang]/blog/[[...slug]]/page.tsx

    Premise re-measured on the branch base (post round-2 merges):

    $ grep -rn "metadataBase\|canonical\|alternates" apps/docs --include='*.ts' --include='*.tsx'
    apps/docs/lib/site.ts:6,11,12,26   <- prose in the docblock only, no usage
    $ grep -rn "generateMetadata\|export const metadata" apps/docs --include='*.ts' --include='*.tsx'
    apps/docs/app/layout.tsx:5
    apps/docs/app/[lang]/page.tsx:24
    apps/docs/app/[lang]/docs/[[...slug]]/page.tsx:56
    apps/docs/app/[lang]/blog/[[...slug]]/page.tsx:183
    

    Positive control returns 4 hits, so the zero is absence, not a broken query. Premise holds.

    apps/docs/lib/site.ts is on main and will be imported, not re-created. apps/docs/app/page.tsx (shadowed, #12255) will not be touched; every claim gets verified against a rendered response.

  6. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    os-dev-report

    {
      "issue": 12234,
      "status": "done",
      "branch": "claude/issue-12234-metadatabase-canonical",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12305",
      "premise_still_valid": true,
      "summary": "Re-measured on the branch base af58a6fbc (post round-2 merges): zero metadataBase/alternates/canonical usage under apps/docs (only prose in lib/site.ts's docblock), positive control 4 hits, and 0 rendered canonicals on every route type. Premise holds. apps/docs/app/layout.tsx now sets metadataBase: new URL(SITE_ORIGIN) importing the existing apps/docs/lib/site.ts (not re-created, no env var, no second origin constant), and the three page files that own metadata each add alternates.canonical built with absoluteUrl(). Absolute rather than metadataBase-relative on purpose: absoluteUrl() throws at build time on a non-site-relative path, so the canonical cannot silently land on another host, and it is the same helper app/sitemap.ts uses, so the two surfaces read one constant. app/page.tsx untouched (shadowed route, #12255) - every claim measured against a rendered response from `next build && next start`, never against an edited file. CORRECTION TO PM ASSUMPTION 3: the route tree has FIVE URL shapes across three page files, not four. The card's list (homepage, docs pages, blog index, blog posts) omits /docs, the empty-slug case of the docs optional catch-all, which resolves content/docs/index.mdx. All five are covered and verified. NOTE, NOT A DEFECT: the homepage canonical renders as https://objectstack.ai while the sitemap emits https://objectstack.ai/ - absoluteUrl('/') returns the trailing slash and Next strips it per trailingSlash:false. RFC 3986 6.2.3 makes an empty path equivalent to /, and search engines normalise them identically, so it is not a duplicate-content signal; every other URL matches byte for byte. Harmonising would mean editing app/sitemap.ts, outside the declared file surface, so it is flagged rather than reached for. No changeset (apps/docs is private:true and publishes nothing); skip-changeset added via the additive endpoint and read back after the size-labeler settled: [documentation, size/s, skip-changeset].",
      "tests": "All at final commit 8480b7598 (git rev-parse --short HEAD from the same run). BEFORE, base af58a6fbc, dev server: canonical_count=0 on /, /docs, /docs/data-modeling/objects, /blog, /blog/protocol-first-development, /docs/x?utm_source=y. AFTER, PRODUCTION server (next build && next start -p 3962), exactly one absolute canonical each: / -> https://objectstack.ai | /docs -> https://objectstack.ai/docs | /docs/data-modeling/objects -> https://objectstack.ai/docs/data-modeling/objects | /blog -> https://objectstack.ai/blog | /blog/protocol-first-development -> https://objectstack.ai/blog/protocol-first-development | /docs/getting-started -> https://objectstack.ai/docs/getting-started | /docs/ai/agents -> https://objectstack.ai/docs/ai/agents | /blog/metadata-driven-architecture -> https://objectstack.ai/blog/metadata-driven-architecture. QUERY-STRING CASE: /docs/data-modeling/objects?utm_source=y&gclid=z -> 200, canonical https://objectstack.ai/docs/data-modeling/objects (identical to the clean URL). NO REGRESSIONS, same prod server: /en 307->/ , /en/docs 307->/docs, /foo.txt 404, /llms.txt 200, /robots.txt 200, /sitemap.xml 200, /docs/getting-started.mdx 200. GATES, each quoted from its own verdict line, exit codes captured before any pipe: `pnpm --filter @objectstack/docs typecheck` exit 0 '✓ Types generated successfully'; `next build` exit 0 '✓ Compiled successfully in 4.9s', 1222 static pages, zero warn lines; check:docs-locale-catch-all '✓ 1 top-level dynamic segment(s), 1 guarded'; check:page-declaration-shape 'OK - 34 page entries across 2184 sources'; check:published-files '✓ 69 publishable package(s) of 78'; check:test-source-alias 'OK - 72 packages with tests scanned'; check:type-source-resolution 'OK - 93 tsc program(s) across 77 packages'; check:nul-bytes 'OK (scanned 6811 text file(s) ... no raw ASCII control bytes)'. Family derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` against the real 4-path changeset; it named the first five, nul-bytes is owed by any edit. ABLATION 1 (base build, metadataBase warning): four files reverted in place to af58a6fbc, mutation proven on disk by git hash-object 16f5cc60 != 0bc2cb10 and grep -c metadataBase = 0, then a full rebuilt `next build` -> exit 0, ZERO metadataBase mentions. Restore leg: git checkout HEAD -- apps/docs/app under an EXIT/INT/TERM trap with absolute paths, proven by git diff HEAD empty AND blob hash 0bc2cb10 == HEAD blob. So the base build never warned - Next only warns when metadata holds a RELATIVE url needing resolution, and the base tree had none. Acceptance bullet 3 is satisfied vacuously; reported rather than ticked. ABLATION 2 (is the warning live at all): metadataBase removed AND a relative openGraph.images added, both legs hash-confirmed and marker-counted on disk before building (removed_text_remaining=0, injected_marker_count=1), rebuilt -> '⚠ metadataBase property in metadata export is not set for resolving social open graph or twitter images, using \"http://localhost:3000\"'. Restored, git diff HEAD empty, both blob hashes matched HEAD. Predicted direction (warning appears) matched the observation. Repo-wide `pnpm lint` NOT run - declared narrowing, stated in the PR body; CI owns that run. Verification also ran UNLOCKED: scripts/pm/os-verify-lock.sh reports no usable flock on this macOS host and printed 'VERDICT command-exit 0 - UNLOCKED (declared)'; its official wording is pasted in the PR body.",
      "open_questions": [],
      "out_of_scope_findings": []
    }
  7. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    ACCEPT — PR #12305, 4 files, +32/-0, pure additions on exactly the declared surface, importing the existing lib/site.ts rather than re-creating it.

    • Correction to my assumption 3, and it is the useful kind: the route tree has five URL shapes, not the four I listed. I omitted /docs — the empty-slug case of the docs optional catch-all, which resolves content/docs/index.mdx. All five now emit exactly one absolute canonical, verified against a next build && next start server rather than against the edited files.
    • Query-string case measured: /docs/data-modeling/objects?utm_source=y&gclid=z canonicalises to the clean URL, byte-identical to what the clean URL emits.
    • An acceptance box reported as vacuous instead of ticked: "the build stops warning about a missing metadataBase" — the base build never warned, because Next only warns when metadata holds a relative URL needing resolution, and there were none. Rather than claim a fix for a warning that never fired, the dev ran a second ablation (remove metadataBase, add a relative openGraph.images) and made the warning appear, proving it is live and that the box is satisfied vacuously rather than untested. That distinction is the whole difference between a verified gate and a green light nobody checked.
    • Absolute canonicals via absoluteUrl() rather than metadataBase-relative ones, deliberately: the helper throws at build time on a non-site-relative path, and it is the same helper app/sitemap.ts uses, so the canonical link and the sitemap entry read one constant and cannot drift.
    • Flagged, not a defect, and I agree with the reading: the homepage canonical renders https://objectstack.ai while the sitemap emits https://objectstack.ai/. RFC 3986 §6.2.3 makes an empty path equivalent to / and search engines normalise them identically. Harmonising would mean editing app/sitemap.ts, outside the declared surface — correctly flagged instead of reached for. No follow-up card: a card for a non-defect is noise.

    ⚠️ For #12235 (Open Graph), which is queued behind this: metadataBase is now set, so relative image URLs resolve. That was the dependency this card existed to satisfy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions