Skip to content

fix(docs): set metadataBase and emit one absolute canonical per page type - #12305

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-12234-metadatabase-canonical
Aug 25, 2026
Merged

fix(docs): set metadataBase and emit one absolute canonical per page type#12305
os-zhuang merged 1 commit into
mainfrom
claude/issue-12234-metadatabase-canonical

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#12234

What changed

Four files, +32/-0.

  • apps/docs/app/layout.tsxmetadataBase: new URL(SITE_ORIGIN), importing the shared constant from apps/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.tsxalternates: { canonical: absoluteUrl('/') } on the homepage's static metadata export.
  • apps/docs/app/[lang]/docs/[[...slug]]/page.tsxalternates: { canonical: absoluteUrl(page.url) } in generateMetadata.
  • apps/docs/app/[lang]/blog/[[...slug]]/page.tsx — the same, on both branches of generateMetadata (index literal /blog, posts page.url).

app/page.tsx is deliberately untouched: proxy.ts rewrites / to /en, so RootPage() never runs (#12255). The served homepage is app/[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-relative

Both 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 of metadataBase remaining set. It is also the same helper app/sitemap.ts uses, 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/app has 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 resolves content/docs/index.mdx.

#URL shapefile
1/ homepageapp/[lang]/page.tsx (static metadata)
2/docs docs indexapp/[lang]/docs/[[...slug]]/page.tsx, slug []
3/docs/** docs pages (403)same file
4/blog blog indexapp/[lang]/blog/[[...slug]]/page.tsx, slug []
5/blog/** blog posts (3)same file

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 af58a6fbc

$ 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'
4 hits <- positive control: the zero above is absence, not a broken query

Rendered, dev server, all five route types plus the query-string case: canonical_count=0 on every one.

After — production server (next build && next start) at commit 8480b7598

ROUTE HTTP N CANONICAL
/ 200 1 https://objectstack.ai
/docs 200 1 https://objectstack.ai/docs
/docs/data-modeling/objects 200 1 https://objectstack.ai/docs/data-modeling/objects
/blog 200 1 https://objectstack.ai/blog
/blog/protocol-first-development 200 1 https://objectstack.ai/blog/protocol-first-development
/docs/data-modeling/objects?utm_source=y&gclid=z 200 1 https://objectstack.ai/docs/data-modeling/objects
/docs/getting-started 200 1 https://objectstack.ai/docs/getting-started
/docs/ai/agents 200 1 https://objectstack.ai/docs/ai/agents
/blog/metadata-driven-architecture 200 1 https://objectstack.ai/blog/metadata-driven-architecture

Exactly one, absolute, on https://objectstack.ai, for every route type. /docs/x?utm_source=y&gclid=z and /docs/x both canonicalise to /docs/x.

No regressions on what round 1 and 2 landed, same production server:

/en -> 307 -> / /foo.txt -> 404 /llms.txt -> 200
/en/docs -> 307 -> /docs /robots.txt-> 200 /sitemap.xml -> 200
/docs/getting-started.mdx -> 200

⚠️ One acceptance bullet is satisfied vacuously — reporting it rather than ticking it

the Next build no longer warns about a missing metadataBase

The base build did not warn either. Measured as an ablation: the four files reverted to af58a6fbc in place, git hash-object confirming the revert landed on disk (16f5cc600bc2cb10), grep -c metadataBase = 0 on disk, then a full next build:

BASE_BUILD_EXIT=0
=== metadataBase mentions in BASE build === (none)

Restored from HEAD afterwards; git diff HEAD empty 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, no alternates. So the bullet describes a warning this codebase was never emitting. It is now unreachable for a different reason: the canonicals are absolute, and metadataBase is set.

The warning is live, though, and the reason metadataBase had to land is real. Second ablation — metadataBase removed and a relative openGraph.images added, both legs hash-confirmed on disk before the build:

⚠ metadataBase property in metadata export is not set for resolving social open graph
or twitter images, using "http://localhost:3000".

Restored and verified byte-identical again.

For #12235 (Open Graph), measured here so it does not have to be rediscovered

The dispatch asked whether metadataBase alone makes the existing /og/docs/** image URLs resolve. It does. Probed by temporarily adding openGraph: { images: getPageImage(page).url } to the docs generateMetadata (trap-guarded, marker-count confirmed on disk, restored to a byte-identical file):

<meta property="og:image" content="https://objectstack.ai/og/docs/data-modeling/objects/image.png"/>

Without metadataBase that same relative path lands on http://localhost:3000 instead (the
second ablation above). The Open Graph card therefore needs no origin work of its own —
just the openGraph block 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

canonical (rendered) https://objectstack.ai
sitemap <loc> https://objectstack.ai/

absoluteUrl('/') returns https://objectstack.ai/; Next normalises the pathname per the trailingSlash config (default false) 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 editing app/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.

commandresult
pnpm --filter @objectstack/docs typecheck✓ Types generated successfully, exit 0
next build (403 docs + 3 posts, 1222 static pages)✓ Compiled successfully, exit 0, zero warn lines
pnpm check:docs-locale-catch-all✓ 1 top-level dynamic segment(s), 1 guarded
pnpm check:page-declaration-shapeOK — 34 page entries across 2184 sources
pnpm check:published-files✓ 69 publishable package(s) of 78
pnpm check:test-source-aliasOK — 72 packages with tests scanned
pnpm check:type-source-resolutionOK — 93 tsc program(s) across 77 packages
pnpm 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/objectstack against the real changeset (4 paths, working tree); it named the first five, and check:nul-bytes is 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 lint was not run. CI owns that run.

Declared narrowing — verification ran UNLOCKED.scripts/pm/os-verify-lock.sh
could not take the shared verify lock on this host: no usable flock. The shared
verify lock is declared Linux-only (flock is util-linux, and a stock macOS does
not 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.

pnpm --filter @objectstack/docs typecheck
cd apps/docs && NODE_OPTIONS=--max-old-space-size=4096 npx next build

No changeset

Docs-site only; apps/docs is private: true and publishes nothing. skip-changeset applied.


Generated by Claude Code

…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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant

@os-zhuang