Uh oh!
There was an error while loading. Please reload this page.
fix(docs): set metadataBase and emit one absolute canonical per page type - #12305
Merged
Conversation
…type Not one page on the docs site emitted `<link rel="canonical">`, and the root layout set no `metadataBase`. Every URL variant of a page — query strings, tracking parameters — was a separate document to a crawler, with nothing declaring which one is real. `apps/docs/app/layout.tsx` now sets `metadataBase: new URL(SITE_ORIGIN)` from the shared origin constant, and each of the three page files that own metadata adds `alternates.canonical` built with `absoluteUrl()`, so the canonical link and the sitemap entry read the same constant and cannot drift. The canonical values are absolute rather than metadataBase-relative on purpose: `absoluteUrl()` throws at build time on anything that is not a site-relative path, so the emitted URL cannot silently land on another host, and it stays absolute independently of `metadataBase` remaining set. `app/page.tsx` is deliberately untouched: `proxy.ts` rewrites `/` to `/en`, so that route never runs (#12255). The homepage's metadata lives in `app/[lang]/page.tsx`, and every claim here was verified against a rendered response rather than the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 25, 2026
os-zhuang
marked this pull request as ready for review
August 25, 2026 17:28
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 25, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#12234
What changed
Four files, +32/-0.
apps/docs/app/layout.tsx—metadataBase: new URL(SITE_ORIGIN), importing the shared constant fromapps/docs/lib/site.ts(landed by feat(docs): serve a real /robots.txt and /sitemap.xml #12253). Not re-created, not read from an env var.apps/docs/app/[lang]/page.tsx—alternates: { canonical: absoluteUrl('/') }on the homepage's staticmetadataexport.apps/docs/app/[lang]/docs/[[...slug]]/page.tsx—alternates: { canonical: absoluteUrl(page.url) }ingenerateMetadata.apps/docs/app/[lang]/blog/[[...slug]]/page.tsx— the same, on both branches ofgenerateMetadata(index literal/blog, postspage.url).app/page.tsxis deliberately untouched:proxy.tsrewrites/to/en, soRootPage()never runs (#12255). The served homepage isapp/[lang]/page.tsx, and every claim below was measured against a rendered response, never against the file that was edited.Why the canonical values are absolute rather than
metadataBase-relativeBoth spellings render identically today.
absoluteUrl()was chosen because it throws at build time on anything that is not a site-relative path, so the emitted URL cannot silently land on another host, and the canonical stays absolute independently ofmetadataBaseremaining set. It is also the same helperapp/sitemap.tsuses, so a canonical link and its sitemap entry read one constant and cannot drift.Route types — enumerated from the route tree, not from a list
apps/docs/apphas three page files owning metadata, serving five URL shapes. The card named four; the fifth is/docs, the empty-slug case of the docs optional catch-all, which resolvescontent/docs/index.mdx./homepageapp/[lang]/page.tsx(staticmetadata)/docsdocs indexapp/[lang]/docs/[[...slug]]/page.tsx, slug[]/docs/**docs pages (403)/blogblog indexapp/[lang]/blog/[[...slug]]/page.tsx, slug[]/blog/**blog posts (3)The remaining routes under
app/emit no HTML document and need no canonical:api/search,llms.txt,llms-full.txt,llms.mdx/docs/[[...slug]],og/docs/[...slug],robots.ts,sitemap.ts.Measured
Before, on the branch base
af58a6fbcRendered, dev server, all five route types plus the query-string case:
canonical_count=0on every one.After — production server (
next build && next start) at commit8480b7598Exactly one, absolute, on
https://objectstack.ai, for every route type./docs/x?utm_source=y&gclid=zand/docs/xboth canonicalise to/docs/x.No regressions on what round 1 and 2 landed, same production server:
The base build did not warn either. Measured as an ablation: the four files reverted to
af58a6fbcin place,git hash-objectconfirming the revert landed on disk (16f5cc60≠0bc2cb10),grep -c metadataBase= 0 on disk, then a fullnext build:Restored from
HEADafterwards;git diff HEADempty and both blob hashes matched.Next only emits that warning when metadata contains a relative URL it must resolve, and the base tree had none — no
openGraph, noalternates. So the bullet describes a warning this codebase was never emitting. It is now unreachable for a different reason: the canonicals are absolute, andmetadataBaseis set.The warning is live, though, and the reason
metadataBasehad to land is real. Second ablation —metadataBaseremoved and a relativeopenGraph.imagesadded, both legs hash-confirmed on disk before the build:Restored and verified byte-identical again.
For #12235 (Open Graph), measured here so it does not have to be rediscovered
The dispatch asked whether
metadataBasealone makes the existing/og/docs/**image URLs resolve. It does. Probed by temporarily addingopenGraph: { images: getPageImage(page).url }to the docsgenerateMetadata(trap-guarded, marker-count confirmed on disk, restored to a byte-identical file):Without
metadataBasethat same relative path lands onhttp://localhost:3000instead (thesecond ablation above). The Open Graph card therefore needs no origin work of its own —
just the
openGraphblock on #12235.Also for #12235 and #12242: this diff adds a single
alternates:property inside each existing metadata object and one import line per file. It does not reorder or restructure anything, so rebases stay trivial.Note, not a defect — the homepage canonical and its sitemap entry differ by a trailing slash
absoluteUrl('/')returnshttps://objectstack.ai/; Next normalises the pathname per thetrailingSlashconfig (defaultfalse) and drops it. The two are the same URL — RFC 3986 §6.2.3 makes an empty path equivalent to/, and search engines normalise them identically — so this is not a duplicate-content signal. Every other URL matches byte for byte between the two surfaces. Harmonising them would mean editingapp/sitemap.ts, which is outside this card's declared file surface; flagging rather than reaching for it.Verification
Commit
8480b7598. Union re-run against the final commit.pnpm --filter @objectstack/docs typecheck✓ Types generated successfully, exit 0next build(403 docs + 3 posts, 1222 static pages)✓ Compiled successfully, exit 0, zero warn linespnpm check:docs-locale-catch-all✓ 1 top-level dynamic segment(s), 1 guardedpnpm check:page-declaration-shapeOK — 34 page entries across 2184 sourcespnpm check:published-files✓ 69 publishable package(s) of 78pnpm check:test-source-aliasOK — 72 packages with tests scannedpnpm check:type-source-resolutionOK — 93 tsc program(s) across 77 packagespnpm check:nul-bytesOK (scanned 6811 text file(s) … no raw ASCII control bytes)Gate family derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackagainst the real changeset (4 paths, working tree); it named the first five, andcheck:nul-bytesis owed by any edit. Every row above quotes the gate's own verdict line, not a$?read through a pipe.Declared narrowing — repo-wide
pnpm lintwas not run. CI owns that run.Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usable
flock. The sharedverify lock is declared Linux-only (
flockis util-linux, and a stock macOS doesnot ship it), so the commands below were run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for this
run, nor for any sibling agent in this container while it ran.
No changeset
Docs-site only;
apps/docsisprivate: trueand publishes nothing.skip-changesetapplied.Generated by Claude Code