From 8480b75986cfc9c86a8d0f3ca5bbb11603ff95c0 Mon Sep 17 00:00:00 2001
From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Date: Wed, 26 Aug 2026 01:11:52 +0800
Subject: [PATCH] fix(docs): set metadataBase and emit one absolute canonical
per page type
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Not one page on the docs site emitted ``, 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
---
apps/docs/app/[lang]/blog/[[...slug]]/page.tsx | 5 +++++
apps/docs/app/[lang]/docs/[[...slug]]/page.tsx | 8 ++++++++
apps/docs/app/[lang]/page.tsx | 8 ++++++++
apps/docs/app/layout.tsx | 11 +++++++++++
4 files changed, 32 insertions(+)
diff --git a/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx b/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx
index 9e50ceb19b..5e34eb0cd5 100644
--- a/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx
+++ b/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx
@@ -3,6 +3,7 @@ import { blog } from '@/lib/source';
import { getMDXComponents } from '@/mdx-components';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
+import { absoluteUrl } from '@/lib/site';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
@@ -192,6 +193,9 @@ export async function generateMetadata({
return {
title: 'Blog',
description: 'Insights, updates, and best practices from the ObjectStack team.',
+ // The index has no MDX file behind it, so its route is spelled out here — the
+ // same literal `app/sitemap.ts` lists it under.
+ alternates: { canonical: absoluteUrl('/blog') },
};
}
@@ -204,5 +208,6 @@ export async function generateMetadata({
return {
title: page.data.title,
description: page.data.description,
+ alternates: { canonical: absoluteUrl(page.url) },
};
}
diff --git a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx
index 55d3a22d79..ed46b9888b 100644
--- a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx
+++ b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx
@@ -9,6 +9,7 @@ import { File, Folder, Files } from 'fumadocs-ui/components/files';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { LLMCopyButton, ViewOptions } from '@/components/ai/page-actions';
import { gitConfig } from '@/lib/layout.shared';
+import { absoluteUrl } from '@/lib/site';
export default async function Page(props: {
params: Promise<{ lang: string; slug?: string[] }>;
@@ -63,5 +64,12 @@ export async function generateMetadata(props: {
return {
title: page.data.title,
description: page.data.description,
+ /**
+ * `page.url` is the same locale-stripped route fumadocs uses for in-site links
+ * and that `app/sitemap.ts` lists, so the canonical link and the sitemap entry
+ * cannot drift apart. `absoluteUrl()` throws rather than emit a URL on another
+ * host if that ever stops being a site-relative path.
+ */
+ alternates: { canonical: absoluteUrl(page.url) },
};
}
diff --git a/apps/docs/app/[lang]/page.tsx b/apps/docs/app/[lang]/page.tsx
index 7f20305e5c..c0b09e8dee 100644
--- a/apps/docs/app/[lang]/page.tsx
+++ b/apps/docs/app/[lang]/page.tsx
@@ -4,6 +4,7 @@ import { ArrowRight, Check } from 'lucide-react';
import { Bricolage_Grotesque, IBM_Plex_Mono } from 'next/font/google';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions, gitConfig } from '@/lib/layout.shared';
+import { absoluteUrl } from '@/lib/site';
import { YouTubeEmbed } from '@/components/youtube-embed';
/** The 90-second overview — the same video the README's hero cover links to. */
@@ -25,6 +26,13 @@ export const metadata: Metadata = {
title: 'Metadata framework for AI-written apps',
description:
'ObjectStack turns the whole app — data model, UI, workflows, permissions — into typed metadata: a complete CRM in under 150k tokens, one context window.',
+ /**
+ * `/` is the one indexable spelling of the homepage: `proxy.ts` rewrites `/` to
+ * this route internally, and the prefixed form `/en` 307s back to `/`. Every
+ * other spelling a crawler reaches it by — query strings, tracking parameters —
+ * points here.
+ */
+ alternates: { canonical: absoluteUrl('/') },
};
const VOCABULARY: { tag: string; title: string; copy: string }[] = [
diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx
index 16f5cc607a..0bc2cb10ae 100644
--- a/apps/docs/app/layout.tsx
+++ b/apps/docs/app/layout.tsx
@@ -1,8 +1,19 @@
import './global.css';
import type { ReactNode } from 'react';
import type { Metadata } from 'next';
+import { SITE_ORIGIN } from '@/lib/site';
export const metadata: Metadata = {
+ /**
+ * The origin every relative URL in this site's metadata resolves against —
+ * canonical links today, the Open Graph / Twitter image paths next. Left unset,
+ * Next resolves them against a build-time guess of the deployment's own origin,
+ * so a preview build would advertise itself as the real site.
+ *
+ * `new URL(...)` at the point of use, so `lib/site.ts` keeps exporting an
+ * immutable string rather than a `URL` instance shared across every route.
+ */
+ metadataBase: new URL(SITE_ORIGIN),
title: {
template: '%s | ObjectStack',
default: 'ObjectStack',