Skip to content

Commit c463ec3

Browse files
os-zhuangclaude
andauthored
fix(docs): set metadataBase and emit one absolute canonical per page type (#12305)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 3685993 commit c463ec3

4 files changed

Lines changed: 32 additions & 0 deletions

File tree

apps/docs/app/[lang]/blog/[[...slug]]/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { blog } from '@/lib/source';
33
import { getMDXComponents } from '@/mdx-components';
44
import { HomeLayout } from 'fumadocs-ui/layouts/home';
55
import { baseOptions } from '@/lib/layout.shared';
6+
import { absoluteUrl } from '@/lib/site';
67
import Link from 'next/link';
78
import { ArrowLeft } from 'lucide-react';
89

@@ -192,6 +193,9 @@ export async function generateMetadata({
192193
return {
193194
title: 'Blog',
194195
description: 'Insights, updates, and best practices from the ObjectStack team.',
196+
// The index has no MDX file behind it, so its route is spelled out here — the
197+
// same literal `app/sitemap.ts` lists it under.
198+
alternates: { canonical: absoluteUrl('/blog') },
195199
};
196200
}
197201

@@ -204,5 +208,6 @@ export async function generateMetadata({
204208
return {
205209
title: page.data.title,
206210
description: page.data.description,
211+
alternates: { canonical: absoluteUrl(page.url) },
207212
};
208213
}

apps/docs/app/[lang]/docs/[[...slug]]/page.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { File, Folder, Files } from 'fumadocs-ui/components/files';
99
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
1010
import { LLMCopyButton, ViewOptions } from '@/components/ai/page-actions';
1111
import { gitConfig } from '@/lib/layout.shared';
12+
import { absoluteUrl } from '@/lib/site';
1213

1314
export default async function Page(props: {
1415
params: Promise<{ lang: string; slug?: string[] }>;
@@ -63,5 +64,12 @@ export async function generateMetadata(props: {
6364
return {
6465
title: page.data.title,
6566
description: page.data.description,
67+
/**
68+
* `page.url` is the same locale-stripped route fumadocs uses for in-site links
69+
* and that `app/sitemap.ts` lists, so the canonical link and the sitemap entry
70+
* cannot drift apart. `absoluteUrl()` throws rather than emit a URL on another
71+
* host if that ever stops being a site-relative path.
72+
*/
73+
alternates: { canonical: absoluteUrl(page.url) },
6674
};
6775
}

apps/docs/app/[lang]/page.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ArrowRight, Check } from 'lucide-react';
44
import { Bricolage_Grotesque, IBM_Plex_Mono } from 'next/font/google';
55
import { HomeLayout } from 'fumadocs-ui/layouts/home';
66
import { baseOptions, gitConfig } from '@/lib/layout.shared';
7+
import { absoluteUrl } from '@/lib/site';
78
import { YouTubeEmbed } from '@/components/youtube-embed';
89

910
/** The 90-second overview — the same video the README's hero cover links to. */
@@ -25,6 +26,13 @@ export const metadata: Metadata = {
2526
title: 'Metadata framework for AI-written apps',
2627
description:
2728
'ObjectStack turns the whole app — data model, UI, workflows, permissions — into typed metadata: a complete CRM in under 150k tokens, one context window.',
29+
/**
30+
* `/` is the one indexable spelling of the homepage: `proxy.ts` rewrites `/` to
31+
* this route internally, and the prefixed form `/en` 307s back to `/`. Every
32+
* other spelling a crawler reaches it by — query strings, tracking parameters —
33+
* points here.
34+
*/
35+
alternates: { canonical: absoluteUrl('/') },
2836
};
2937

3038
const VOCABULARY: { tag: string; title: string; copy: string }[] = [

apps/docs/app/layout.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
import './global.css';
22
import type { ReactNode } from 'react';
33
import type { Metadata } from 'next';
4+
import { SITE_ORIGIN } from '@/lib/site';
45

56
export const metadata: Metadata = {
7+
/**
8+
* The origin every relative URL in this site's metadata resolves against —
9+
* canonical links today, the Open Graph / Twitter image paths next. Left unset,
10+
* Next resolves them against a build-time guess of the deployment's own origin,
11+
* so a preview build would advertise itself as the real site.
12+
*
13+
* `new URL(...)` at the point of use, so `lib/site.ts` keeps exporting an
14+
* immutable string rather than a `URL` instance shared across every route.
15+
*/
16+
metadataBase: new URL(SITE_ORIGIN),
617
title: {
718
template: '%s | ObjectStack',
819
default: 'ObjectStack',

0 commit comments

Comments
 (0)