Skip to content

Commit f334274

Browse files
committed
feat(seo): enhance SEO handling with canonical links and indexing logic
1 parent a02e416 commit f334274

File tree

6 files changed

+136
-27
lines changed

6 files changed

+136
-27
lines changed

src/routes/__root.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import {
99
} from '@tanstack/react-router'
1010
import { QueryClient } from '@tanstack/react-query'
1111
import appCss from '~/styles/app.css?url'
12-
import { seo } from '~/utils/seo'
12+
import {
13+
canonicalUrl,
14+
getCanonicalPath,
15+
seo,
16+
shouldIndexPath,
17+
} from '~/utils/seo'
1318
import ogImage from '~/images/og.png'
1419
const LazyRouterDevtools = React.lazy(() =>
1520
import('@tanstack/react-router-devtools').then((m) => ({
@@ -155,6 +160,12 @@ function ShellComponent({ children }: { children: React.ReactNode }) {
155160
select: (s) => s.resolvedLocation?.pathname.startsWith('/router'),
156161
})
157162

163+
const canonicalPath = useRouterState({
164+
select: (s) => s.resolvedLocation?.pathname || '/',
165+
})
166+
167+
const preferredCanonicalPath = getCanonicalPath(canonicalPath)
168+
158169
const showDevtools = canShowLoading && isRouterPage
159170

160171
const hideNavbar = useMatches({
@@ -166,6 +177,12 @@ function ShellComponent({ children }: { children: React.ReactNode }) {
166177
return (
167178
<html lang="en" className={htmlClass} suppressHydrationWarning>
168179
<head>
180+
{preferredCanonicalPath ? (
181+
<link rel="canonical" href={canonicalUrl(preferredCanonicalPath)} />
182+
) : null}
183+
{!shouldIndexPath(canonicalPath) ? (
184+
<meta name="robots" content="noindex, nofollow" />
185+
) : null}
169186
<HeadContent />
170187
{hasBaseParent ? <base target="_parent" /> : null}
171188
</head>

src/routes/blog.index.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { LibrariesWidget } from '~/components/LibrariesWidget'
1515
import { partners } from '~/utils/partners'
1616
import { PartnersRail, RightRail } from '~/components/RightRail'
1717
import { RecentPostsWidget } from '~/components/RecentPostsWidget'
18+
import { seo } from '~/utils/seo'
1819

1920
type BlogFrontMatter = {
2021
slug: string
@@ -60,11 +61,10 @@ export const Route = createFileRoute('/blog/')({
6061
notFoundComponent: () => <PostNotFound />,
6162
component: BlogIndex,
6263
head: () => ({
63-
meta: [
64-
{
65-
title: 'Blog',
66-
},
67-
],
64+
meta: seo({
65+
title: 'Blog | TanStack',
66+
description: 'The latest news and blog posts from TanStack.',
67+
}),
6868
}),
6969
})
7070

src/routes/libraries.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,15 @@ import * as React from 'react'
33
import { libraries, Library } from '~/libraries'
44
import { reactChartsProject } from '~/libraries/react-charts'
55
import LibraryCard from '~/components/LibraryCard'
6+
import { seo } from '~/utils/seo'
67

78
export const Route = createFileRoute('/libraries')({
89
component: LibrariesPage,
910
head: () => ({
10-
meta: [
11-
{
12-
title: 'All Libraries - TanStack',
13-
},
14-
{
15-
name: 'description',
16-
content: 'Browse all TanStack libraries.',
17-
},
18-
],
11+
meta: seo({
12+
title: 'All Libraries - TanStack',
13+
description: 'Browse all TanStack libraries.',
14+
}),
1915
}),
2016
})
2117

src/routes/showcase/index.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ const searchSchema = v.object({
1616

1717
export const PAGE_SIZE_OPTIONS = [24, 48, 96, 192] as const
1818

19+
function hasNonCanonicalSearch(search: v.InferOutput<typeof searchSchema>) {
20+
return Boolean(
21+
search.page > 1 ||
22+
search.pageSize !== PAGE_SIZE_OPTIONS[0] ||
23+
search.libraryIds?.length ||
24+
search.useCases?.length ||
25+
search.hasSourceCode ||
26+
search.q,
27+
)
28+
}
29+
1930
export const Route = createFileRoute('/showcase/')({
2031
validateSearch: searchSchema,
2132
loaderDeps: ({ search }) => ({
@@ -41,13 +52,18 @@ export const Route = createFileRoute('/showcase/')({
4152
},
4253
}),
4354
)
55+
56+
return {
57+
hasNonCanonicalSearch: hasNonCanonicalSearch(deps),
58+
}
4459
},
4560
component: ShowcaseGallery,
46-
head: () => ({
61+
head: ({ loaderData }) => ({
4762
meta: seo({
4863
title: 'Showcase | TanStack',
4964
description:
5065
'Discover projects built with TanStack libraries. See how developers are using TanStack Query, Router, Table, Form, and more in production.',
66+
noindex: loaderData?.hasNonCanonicalSearch,
5167
}),
5268
}),
5369
})

src/utils/seo.ts

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,98 @@
1+
import { env } from '~/utils/env'
2+
import { findLibrary } from '~/libraries'
3+
4+
const DEFAULT_SITE_URL = 'https://tanstack.com'
5+
const NON_INDEXABLE_PATH_PREFIXES = ['/account', '/admin', '/login'] as const
6+
7+
function trimTrailingSlash(value: string) {
8+
return value.replace(/\/$/, '')
9+
}
10+
11+
function normalizePath(path: string) {
12+
if (!path || path === '/') {
13+
return '/'
14+
}
15+
16+
const normalizedPath = path.startsWith('/') ? path : `/${path}`
17+
18+
return normalizedPath.replace(/\/$/, '')
19+
}
20+
21+
export function getCanonicalPath(path: string) {
22+
const normalizedPath = normalizePath(path)
23+
24+
if (
25+
NON_INDEXABLE_PATH_PREFIXES.some(
26+
(prefix) =>
27+
normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`),
28+
)
29+
) {
30+
return null
31+
}
32+
33+
const pathSegments = normalizedPath.split('/').filter(Boolean)
34+
35+
if (pathSegments.length >= 2) {
36+
const [libraryId, version, ...rest] = pathSegments
37+
const library = findLibrary(libraryId)
38+
39+
if (library && version !== 'latest') {
40+
return normalizePath(`/${library.id}/latest/${rest.join('/')}`)
41+
}
42+
}
43+
44+
return normalizedPath
45+
}
46+
47+
export function shouldIndexPath(path: string) {
48+
return getCanonicalPath(path) !== null
49+
}
50+
51+
export function canonicalUrl(path: string) {
52+
const origin = trimTrailingSlash(
53+
env.URL ||
54+
(import.meta.env.SSR ? env.SITE_URL : undefined) ||
55+
DEFAULT_SITE_URL,
56+
)
57+
58+
return `${origin}${normalizePath(path)}`
59+
}
60+
61+
export function canonicalLink(path: string) {
62+
return [{ rel: 'canonical', href: canonicalUrl(path) }]
63+
}
64+
65+
type SeoOptions = {
66+
title: string
67+
description?: string
68+
image?: string
69+
keywords?: string
70+
noindex?: boolean
71+
}
72+
73+
type HeadWithCanonical = {
74+
meta?: Array<Record<string, string | undefined>>
75+
links?: Array<{ rel: string; href: string }>
76+
}
77+
78+
export function withCanonical(path: string, head: HeadWithCanonical = {}) {
79+
return {
80+
...head,
81+
links: [...canonicalLink(path), ...(head.links ?? [])],
82+
}
83+
}
84+
85+
export function seoWithCanonical(path: string, options: SeoOptions) {
86+
return withCanonical(path, { meta: seo(options) })
87+
}
88+
189
export const seo = ({
290
title,
391
description,
492
keywords,
593
image,
694
noindex,
7-
}: {
8-
title: string
9-
description?: string
10-
image?: string
11-
keywords?: string
12-
noindex?: boolean
13-
}) => {
95+
}: SeoOptions) => {
1496
const tags = [
1597
{ title },
1698
{ name: 'description', content: description },

src/utils/sitemap.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,8 @@ export function generateRobotsTxt(origin: string) {
252252
return [
253253
'User-agent: *',
254254
'Allow: /',
255-
'Disallow: /admin',
256-
'Disallow: /account',
257-
'Disallow: /api',
258-
'Disallow: /oauth',
255+
'Disallow: /api/',
256+
'Disallow: /oauth/',
259257
'',
260258
`Sitemap: ${origin}/sitemap.xml`,
261259
].join('\n')

0 commit comments

Comments
 (0)