Skip to content

Commit 83056ab

Browse files
authored
fix(seo): canonicalize library landing pages to /latest (#1154)
* fix(seo): consolidate library landings on latest * fix(seo): keep versioned landings renderable * fix(router): avoid ambiguous library alias route * fix(router): disambiguate library landing links * fix(router): avoid preloading library aliases
1 parent c3da893 commit 83056ab

9 files changed

Lines changed: 134 additions & 45 deletions

File tree

src/components/LibrariesWidget.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { publicLibraries } from '~/libraries'
44
const featuredLibraries = publicLibraries.slice(0, 8).map((library) => ({
55
id: library.id,
66
name: library.name,
7+
to: library.to,
78
}))
89

910
export function LibrariesWidget() {
@@ -18,8 +19,7 @@ export function LibrariesWidget() {
1819
{featuredLibraries.map((library) => (
1920
<Link
2021
key={library.id}
21-
to="/$libraryId"
22-
params={{ libraryId: library.id }}
22+
to={library.to as never}
2323
className="text-xs opacity-70 hover:opacity-100 hover:underline"
2424
>
2525
{library.name.replace('TanStack ', '')}

src/components/LibraryLayout.tsx

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { Card } from './Card'
3838
import { PartnersRail, RightRail } from './RightRail'
3939
import { trackEvent, useTrackedImpression } from '~/utils/analytics'
4040
import {
41+
getLibraryTabLinkOptions,
4142
getMenuGroupInitialOpenState,
4243
isChartsCatalogTarget,
4344
} from './library-layout-navigation'
@@ -1183,23 +1184,19 @@ export function LibraryLayout({
11831184
return null
11841185
}
11851186

1186-
const linkParams =
1187-
!target.to.startsWith('/') ||
1188-
target.to.includes('/$libraryId')
1189-
? ({ libraryId, version } as never)
1190-
: undefined
1187+
const linkOptions = getLibraryTabLinkOptions({
1188+
libraryId,
1189+
version,
1190+
to: target.to,
1191+
})
11911192
const isActive = tab.id === activeTabId
11921193

11931194
return (
11941195
<li key={tab.id}>
11951196
<Link
1196-
from={
1197-
isChartsCatalogTarget(target.to)
1198-
? undefined
1199-
: '/$libraryId/$version/docs'
1200-
}
1201-
to={target.to}
1202-
params={linkParams}
1197+
from={linkOptions.from as never}
1198+
to={linkOptions.to as never}
1199+
params={linkOptions.params as never}
12031200
onClick={closeMobileMenu}
12041201
preload={
12051202
isChartsCatalogTarget(target.to) ? false : 'intent'
@@ -1374,21 +1371,18 @@ export function LibraryLayout({
13741371
return null
13751372
}
13761373

1377-
const linkParams =
1378-
!target.to.startsWith('/') || target.to.includes('/$libraryId')
1379-
? ({ libraryId, version } as never)
1380-
: undefined
1374+
const linkOptions = getLibraryTabLinkOptions({
1375+
libraryId,
1376+
version,
1377+
to: target.to,
1378+
})
13811379

13821380
return (
13831381
<Link
13841382
key={tab.id}
1385-
from={
1386-
isChartsCatalogTarget(target.to)
1387-
? undefined
1388-
: '/$libraryId/$version/docs'
1389-
}
1390-
to={target.to}
1391-
params={linkParams}
1383+
from={linkOptions.from as never}
1384+
to={linkOptions.to as never}
1385+
params={linkOptions.params as never}
13921386
preload={isChartsCatalogTarget(target.to) ? false : 'intent'}
13931387
activeOptions={{
13941388
exact: true,

src/components/library-layout-navigation.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ export function isChartsCatalogTarget(to: string) {
44
return to === '/charts/catalog' || to.startsWith('/charts/catalog/')
55
}
66

7+
export function getLibraryTabLinkOptions({
8+
libraryId,
9+
version,
10+
to,
11+
}: {
12+
libraryId: string
13+
version: string
14+
to: string
15+
}) {
16+
const isHomeTarget = to === '..'
17+
18+
return {
19+
from:
20+
isHomeTarget || isChartsCatalogTarget(to)
21+
? undefined
22+
: '/$libraryId/$version/docs',
23+
to: isHomeTarget ? `/${libraryId}/${version}` : to,
24+
params:
25+
!isHomeTarget && (!to.startsWith('/') || to.includes('/$libraryId'))
26+
? { libraryId, version }
27+
: undefined,
28+
}
29+
}
30+
731
function normalizeMenuPath(path: string) {
832
return path.replace(/\/+$/, '')
933
}

src/libraries/libraries.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const query: LibrarySlim = {
1111
id: 'query',
1212
...categoryStyles.data,
1313
name: 'TanStack Query',
14-
to: '/query',
14+
to: '/query/latest',
1515
tagline:
1616
'Powerful asynchronous state management, server-state utilities and data fetching',
1717
description:
@@ -197,7 +197,7 @@ export const router: LibrarySlim = {
197197
...categoryStyles.framework,
198198
name: 'TanStack Router',
199199
installPath: 'framework/$framework/quick-start',
200-
to: '/router',
200+
to: '/router/latest',
201201
tagline: 'Type-safe Routing for React and Solid applications',
202202
description:
203203
'A powerful React router for client-side and full-stack react applications. Fully type-safe APIs, first-class search-params for managing state in the URL and seamless integration with the existing React ecosystem.',
@@ -259,7 +259,7 @@ export const start: LibrarySlim = {
259259
id: 'start',
260260
...categoryStyles.framework,
261261
name: 'TanStack Start',
262-
to: '/start',
262+
to: '/start/latest',
263263
tagline:
264264
'Full-stack Framework powered by TanStack Router for React and Solid',
265265
description:
@@ -291,7 +291,7 @@ export const table: LibrarySlim = {
291291
id: 'table',
292292
...categoryStyles.ui,
293293
name: 'TanStack Table',
294-
to: '/table',
294+
to: '/table/latest',
295295
tagline: 'Headless, type-safe table and data-grid infrastructure',
296296
description:
297297
'Build tables and data grids with feature-level tree shaking, reactive state, fast row models, and complete control over markup and styles.',
@@ -337,7 +337,7 @@ export const charts: LibrarySlim = {
337337
id: 'charts',
338338
...categoryStyles.ui,
339339
name: 'TanStack Charts',
340-
to: '/charts',
340+
to: '/charts/latest',
341341
tagline: "A chart grammar you don't have to outgrow.",
342342
description:
343343
'A typed, tree-shakable chart grammar for SVG and Canvas. Compose marks, views, scales, transforms, interactions, and motion with compact primitives or D3-compatible inputs.',
@@ -426,7 +426,7 @@ export const form: LibrarySlim = {
426426
id: 'form',
427427
...categoryStyles.ui,
428428
name: 'TanStack Form',
429-
to: '/form',
429+
to: '/form/latest',
430430
tagline: 'Headless UI for building performant and type-safe forms',
431431
description:
432432
'Headless, performant, and type-safe form state management for TS/JS, React, Preact, Vue, Angular, Solid, Lit and Svelte.',
@@ -449,7 +449,7 @@ export const virtual: LibrarySlim = {
449449
id: 'virtual',
450450
...categoryStyles.performance,
451451
name: 'TanStack Virtual',
452-
to: '/virtual',
452+
to: '/virtual/latest',
453453
tagline: 'Headless UI for Virtualizing Large Element Lists',
454454
description:
455455
'Virtualize only the visible content for massive scrollable DOM nodes at 60FPS in TS/JS, React, Vue, Solid, Svelte, Lit, Angular & Marko while retaining 100% control over markup and styles.',
@@ -499,7 +499,7 @@ export const store: LibrarySlim = {
499499
id: 'store',
500500
...categoryStyles.data,
501501
name: 'TanStack Store',
502-
to: '/store',
502+
to: '/store/latest',
503503
tagline: 'Framework agnostic data store with reactive framework adapters',
504504
description:
505505
'The immutable-reactive data store that powers the core of TanStack libraries and their framework adapters.',
@@ -532,7 +532,7 @@ export const pacer: LibrarySlim = {
532532
id: 'pacer',
533533
...categoryStyles.performance,
534534
name: 'TanStack Pacer',
535-
to: '/pacer',
535+
to: '/pacer/latest',
536536
tagline:
537537
'Framework agnostic debouncing, throttling, rate limiting, queuing, and batching utilities',
538538
description:
@@ -572,7 +572,7 @@ export const hotkeys: LibrarySlim = {
572572
id: 'hotkeys',
573573
...categoryStyles.ui,
574574
name: 'TanStack Hotkeys',
575-
to: '/hotkeys',
575+
to: '/hotkeys/latest',
576576
tagline:
577577
'Type-safe keyboard shortcuts, sequences, and key state tracking for your apps',
578578
description:
@@ -605,7 +605,7 @@ export const markdown: LibrarySlim = {
605605
id: 'markdown',
606606
name: 'TanStack Markdown',
607607
cardStyles: 'text-fuchsia-500 dark:text-fuchsia-400 hover:border-current',
608-
to: '/markdown',
608+
to: '/markdown/latest',
609609
tagline: 'A serializable document model for docs and AI streams',
610610
description:
611611
'A deliberately bounded Markdown parser with a public serializable AST, safe defaults, deterministic React, HTML, and Octane output, and a stateless profile for accumulated AI streams.',
@@ -635,7 +635,7 @@ export const highlight: LibrarySlim = {
635635
id: 'highlight',
636636
name: 'TanStack Highlight',
637637
cardStyles: 'text-amber-500 dark:text-amber-400 hover:border-current',
638-
to: '/highlight',
638+
to: '/highlight/latest',
639639
tagline: 'Web-first syntax highlighting with compact, themeable HTML',
640640
description:
641641
'A synchronous syntax highlighter with selective language imports, context-aware web scanners, semantic CSS themes, precise annotations, and compact deterministic output.',
@@ -665,7 +665,7 @@ export const db: LibrarySlim = {
665665
id: 'db',
666666
...categoryStyles.data,
667667
name: 'TanStack DB',
668-
to: '/db',
668+
to: '/db/latest',
669669
tagline: 'The reactive client-first store for your API',
670670
description:
671671
'TanStack DB gives you a reactive, client-first store for your API data with collections, live queries and optimistic mutations that keep your UI reactive, consistent and blazing fast 🔥',
@@ -689,7 +689,7 @@ export const ai: LibrarySlim = {
689689
id: 'ai',
690690
...categoryStyles.data,
691691
name: 'TanStack AI',
692-
to: '/ai',
692+
to: '/ai/latest',
693693
tagline: 'The headless agent framework for TypeScript. Bring your own stack',
694694
description:
695695
'The headless agent framework for TypeScript. TanStack AI runs the agent loop as typed primitives you compose yourself: tool calls, reasoning, human-in-the-loop interrupts, memory, and streaming state. Eleven provider adapters, seven UI framework bindings, sandboxed code execution, MCP, and coding-agent harnesses behind one interface. Native AG-UI over the wire, MIT licensed, no hosted gateway and no platform to buy into.',
@@ -738,7 +738,7 @@ export const intent: LibrarySlim = {
738738
id: 'intent',
739739
...categoryStyles.tooling,
740740
name: 'TanStack Intent',
741-
to: '/intent',
741+
to: '/intent/latest',
742742
tagline: 'Ship Agent Skills with your npm Packages',
743743
description:
744744
"Generate, validate, and ship Agent Skills alongside your library — versioned knowledge that agents discover automatically from node_modules. Skills live in each library's repo and update when the package updates.",
@@ -761,7 +761,7 @@ export const config: LibrarySlim = {
761761
id: 'config',
762762
...categoryStyles.tooling,
763763
name: 'TanStack Config',
764-
to: '/config',
764+
to: '/config/latest',
765765
tagline:
766766
'Configuration and tools for publishing and maintaining high-quality JavaScript packages',
767767
description:
@@ -784,7 +784,7 @@ export const devtools: LibrarySlim = {
784784
id: 'devtools',
785785
...categoryStyles.tooling,
786786
name: 'TanStack Devtools',
787-
to: '/devtools',
787+
to: '/devtools/latest',
788788
tagline:
789789
'Centralized devtools panel for TanStack libraries and other custom devtools',
790790
description:
@@ -848,7 +848,7 @@ export const cli: LibrarySlim = {
848848
id: 'cli',
849849
...categoryStyles.tooling,
850850
name: 'TanStack CLI',
851-
to: '/cli',
851+
to: '/cli/latest',
852852
tagline: 'CLI and project scaffolding toolkit for TanStack',
853853
description:
854854
'A CLI toolkit for TanStack. Create and customize TanStack Start apps, search docs, inspect add-ons, and generate project changes with current TanStack context.',

src/routes/_library/$libraryId/index.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import { redirect, createFileRoute } from '@tanstack/react-router'
33
export const Route = createFileRoute('/_library/$libraryId/')({
44
beforeLoad: ({ params }) => {
55
throw redirect({
6-
to: '/$libraryId/$version',
7-
params: { libraryId: params.libraryId, version: 'latest' } as never,
6+
href: `/${params.libraryId}/latest`,
87
})
98
},
109
})

src/utils/seo.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { SITE_URL } from '~/utils/site'
2+
import { findLibrary } from '~/libraries/libraries'
23

34
const NON_INDEXABLE_PATH_PREFIXES = [
45
'/account',
@@ -34,6 +35,18 @@ export function getCanonicalPath(path: string) {
3435
return null
3536
}
3637

38+
const [, libraryId, version, ...remainingSegments] = normalizedPath.split('/')
39+
const library = libraryId ? findLibrary(libraryId) : undefined
40+
41+
// Numbered and historical landing pages render independently, but all of
42+
// them consolidate their indexing and social URL signals onto /latest.
43+
if (
44+
library?.availableVersions.concat('latest').includes(version!) &&
45+
remainingSegments.length === 0
46+
) {
47+
return `/${library.id}/latest`
48+
}
49+
3750
return normalizedPath
3851
}
3952

tests/charts-sidebar-navigation.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from 'node:assert/strict'
22
import {
3+
getLibraryTabLinkOptions,
34
getMenuGroupInitialOpenState,
45
isChartsCatalogTarget,
56
} from '../src/components/library-layout-navigation'
@@ -9,6 +10,34 @@ assert.equal(isChartsCatalogTarget('/charts/catalog/charts/01-line'), true)
910
assert.equal(isChartsCatalogTarget('/charts/catalog/collections/shadcn'), true)
1011
assert.equal(isChartsCatalogTarget('/charts/catalogue'), false)
1112

13+
assert.deepEqual(
14+
getLibraryTabLinkOptions({
15+
libraryId: 'table',
16+
version: 'latest',
17+
to: '..',
18+
}),
19+
{
20+
from: undefined,
21+
to: '/table/latest',
22+
params: undefined,
23+
},
24+
'a landing-page Home tab uses its concrete route instead of the generic library route',
25+
)
26+
27+
assert.deepEqual(
28+
getLibraryTabLinkOptions({
29+
libraryId: 'table',
30+
version: 'v8',
31+
to: 'guide/intro',
32+
}),
33+
{
34+
from: '/$libraryId/$version/docs',
35+
to: 'guide/intro',
36+
params: { libraryId: 'table', version: 'v8' },
37+
},
38+
'relative docs tabs retain their generic docs-route context',
39+
)
40+
1241
const groups = [
1342
{
1443
label: 'Individual Charts',

tests/libraries-utils.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import assert from 'node:assert/strict'
22
import { orderFrameworksForBrowse } from '../src/libraries/browse-utils'
3+
import { publicLibraries } from '../src/libraries/libraries'
34
import type { Framework } from '../src/libraries/types'
45

6+
for (const library of publicLibraries) {
7+
assert.equal(
8+
library.to,
9+
`/${library.id}/latest`,
10+
`${library.name} links directly to its canonical landing page`,
11+
)
12+
}
13+
514
const frameworks: Array<{ value: Framework }> = [
615
{ value: 'react' },
716
{ value: 'vue' },

0 commit comments

Comments
 (0)