diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts
index 6efad91f8d7..0160f355d98 100644
--- a/e2e/react-start/basic/src/routeTree.gen.ts
+++ b/e2e/react-start/basic/src/routeTree.gen.ts
@@ -24,6 +24,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as SpecialCharsRouteRouteImport } from './routes/specialChars/route'
import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route'
import { Route as NotFoundRouteRouteImport } from './routes/not-found/route'
+import { Route as CanonicalRouteRouteImport } from './routes/canonical/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as UsersIndexRouteImport } from './routes/users.index'
import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index'
@@ -53,6 +54,7 @@ import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-co
import { Route as ApiUsersRouteImport } from './routes/api.users'
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
import { Route as SpecialCharsMalformedRouteRouteImport } from './routes/specialChars/malformed/route'
+import { Route as CanonicalDeepRouteRouteImport } from './routes/canonical/deep/route'
import { Route as RedirectTargetIndexRouteImport } from './routes/redirect/$target/index'
import { Route as SpecialCharsMalformedSearchRouteImport } from './routes/specialChars/malformed/search'
import { Route as SpecialCharsMalformedParamRouteImport } from './routes/specialChars/malformed/$param'
@@ -143,6 +145,11 @@ const NotFoundRouteRoute = NotFoundRouteRouteImport.update({
path: '/not-found',
getParentRoute: () => rootRouteImport,
} as any)
+const CanonicalRouteRoute = CanonicalRouteRouteImport.update({
+ id: '/canonical',
+ path: '/canonical',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -292,6 +299,11 @@ const SpecialCharsMalformedRouteRoute =
path: '/malformed',
getParentRoute: () => SpecialCharsRouteRoute,
} as any)
+const CanonicalDeepRouteRoute = CanonicalDeepRouteRouteImport.update({
+ id: '/deep',
+ path: '/deep',
+ getParentRoute: () => CanonicalRouteRoute,
+} as any)
const RedirectTargetIndexRoute = RedirectTargetIndexRouteImport.update({
id: '/',
path: '/',
@@ -377,6 +389,7 @@ const FooBarQuxHereIndexRoute = FooBarQuxHereIndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
@@ -391,6 +404,7 @@ export interface FileRoutesByFullPath {
'/stream': typeof StreamRoute
'/type-only-reexport': typeof TypeOnlyReexportRoute
'/users': typeof UsersRouteWithChildren
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
@@ -436,6 +450,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/async-scripts': typeof AsyncScriptsRoute
'/client-only': typeof ClientOnlyRoute
@@ -445,6 +460,7 @@ export interface FileRoutesByTo {
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
'/type-only-reexport': typeof TypeOnlyReexportRoute
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
@@ -489,6 +505,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
@@ -504,6 +521,7 @@ export interface FileRoutesById {
'/stream': typeof StreamRoute
'/type-only-reexport': typeof TypeOnlyReexportRoute
'/users': typeof UsersRouteWithChildren
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
@@ -552,6 +570,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/canonical'
| '/not-found'
| '/search-params'
| '/specialChars'
@@ -566,6 +585,7 @@ export interface FileRouteTypes {
| '/stream'
| '/type-only-reexport'
| '/users'
+ | '/canonical/deep'
| '/specialChars/malformed'
| '/api/users'
| '/multi-cookie-redirect/target'
@@ -611,6 +631,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/canonical'
| '/specialChars'
| '/async-scripts'
| '/client-only'
@@ -620,6 +641,7 @@ export interface FileRouteTypes {
| '/scripts'
| '/stream'
| '/type-only-reexport'
+ | '/canonical/deep'
| '/specialChars/malformed'
| '/api/users'
| '/multi-cookie-redirect/target'
@@ -663,6 +685,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
+ | '/canonical'
| '/not-found'
| '/search-params'
| '/specialChars'
@@ -678,6 +701,7 @@ export interface FileRouteTypes {
| '/stream'
| '/type-only-reexport'
| '/users'
+ | '/canonical/deep'
| '/specialChars/malformed'
| '/_layout/_layout-2'
| '/api/users'
@@ -725,6 +749,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ CanonicalRouteRoute: typeof CanonicalRouteRouteWithChildren
NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren
SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren
SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren
@@ -856,6 +881,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof NotFoundRouteRouteImport
parentRoute: typeof rootRouteImport
}
+ '/canonical': {
+ id: '/canonical'
+ path: '/canonical'
+ fullPath: '/canonical'
+ preLoaderRoute: typeof CanonicalRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -1059,6 +1091,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SpecialCharsMalformedRouteRouteImport
parentRoute: typeof SpecialCharsRouteRoute
}
+ '/canonical/deep': {
+ id: '/canonical/deep'
+ path: '/deep'
+ fullPath: '/canonical/deep'
+ preLoaderRoute: typeof CanonicalDeepRouteRouteImport
+ parentRoute: typeof CanonicalRouteRoute
+ }
'/redirect/$target/': {
id: '/redirect/$target/'
path: '/'
@@ -1167,6 +1206,18 @@ declare module '@tanstack/react-router' {
}
}
+interface CanonicalRouteRouteChildren {
+ CanonicalDeepRouteRoute: typeof CanonicalDeepRouteRoute
+}
+
+const CanonicalRouteRouteChildren: CanonicalRouteRouteChildren = {
+ CanonicalDeepRouteRoute: CanonicalDeepRouteRoute,
+}
+
+const CanonicalRouteRouteWithChildren = CanonicalRouteRoute._addFileChildren(
+ CanonicalRouteRouteChildren,
+)
+
interface NotFoundRouteRouteChildren {
NotFoundViaBeforeLoadRoute: typeof NotFoundViaBeforeLoadRoute
NotFoundViaLoaderRoute: typeof NotFoundViaLoaderRoute
@@ -1359,6 +1410,7 @@ const FooBarQuxHereRouteWithChildren = FooBarQuxHereRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ CanonicalRouteRoute: CanonicalRouteRouteWithChildren,
NotFoundRouteRoute: NotFoundRouteRouteWithChildren,
SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren,
SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren,
diff --git a/e2e/react-start/basic/src/routes/canonical/deep/route.tsx b/e2e/react-start/basic/src/routes/canonical/deep/route.tsx
new file mode 100644
index 00000000000..eeeb074f14d
--- /dev/null
+++ b/e2e/react-start/basic/src/routes/canonical/deep/route.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/canonical/deep')({
+ head: () => ({
+ links: [{ rel: 'canonical', href: 'https://example.com/canonical/deep' }],
+ }),
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return
Hello "/canonical/deep"!
+}
diff --git a/e2e/react-start/basic/src/routes/canonical/route.tsx b/e2e/react-start/basic/src/routes/canonical/route.tsx
new file mode 100644
index 00000000000..653ac880b35
--- /dev/null
+++ b/e2e/react-start/basic/src/routes/canonical/route.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/canonical')({
+ head: () => ({
+ links: [{ rel: 'canonical', href: 'https://example.com/canonical' }],
+ }),
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return Hello "/canonical"!
+}
diff --git a/e2e/react-start/basic/tests/canonical.spec.ts b/e2e/react-start/basic/tests/canonical.spec.ts
new file mode 100644
index 00000000000..ff35bb0832d
--- /dev/null
+++ b/e2e/react-start/basic/tests/canonical.spec.ts
@@ -0,0 +1,18 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+
+test('Deduplicates child canonical links over parent', async ({ page }) => {
+ await page.goto('/canonical/deep')
+ await page.waitForURL('/canonical/deep')
+
+ await expect(page.locator('link[rel="canonical"]')).toHaveCount(1)
+
+ // Get all canonical links
+ const links = await page.locator('link[rel="canonical"]').all()
+ expect(links).toHaveLength(1)
+
+ await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
+ 'href',
+ 'https://example.com/canonical/deep',
+ )
+})
diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts
index ad5f5431487..ef4ea8e08ac 100644
--- a/e2e/solid-start/basic/src/routeTree.gen.ts
+++ b/e2e/solid-start/basic/src/routeTree.gen.ts
@@ -21,6 +21,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as SpecialCharsRouteRouteImport } from './routes/specialChars/route'
import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route'
import { Route as NotFoundRouteRouteImport } from './routes/not-found/route'
+import { Route as CanonicalRouteRouteImport } from './routes/canonical/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as UsersIndexRouteImport } from './routes/users.index'
import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index'
@@ -50,6 +51,7 @@ import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-co
import { Route as ApiUsersRouteImport } from './routes/api/users'
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
import { Route as SpecialCharsMalformedRouteRouteImport } from './routes/specialChars/malformed/route'
+import { Route as CanonicalDeepRouteRouteImport } from './routes/canonical/deep/route'
import { Route as RedirectTargetIndexRouteImport } from './routes/redirect/$target/index'
import { Route as TransitionTypingCreateResourceRouteImport } from './routes/transition/typing/create-resource'
import { Route as TransitionCountCreateResourceRouteImport } from './routes/transition/count/create-resource'
@@ -125,6 +127,11 @@ const NotFoundRouteRoute = NotFoundRouteRouteImport.update({
path: '/not-found',
getParentRoute: () => rootRouteImport,
} as any)
+const CanonicalRouteRoute = CanonicalRouteRouteImport.update({
+ id: '/canonical',
+ path: '/canonical',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -274,6 +281,11 @@ const SpecialCharsMalformedRouteRoute =
path: '/malformed',
getParentRoute: () => SpecialCharsRouteRoute,
} as any)
+const CanonicalDeepRouteRoute = CanonicalDeepRouteRouteImport.update({
+ id: '/deep',
+ path: '/deep',
+ getParentRoute: () => CanonicalRouteRoute,
+} as any)
const RedirectTargetIndexRoute = RedirectTargetIndexRouteImport.update({
id: '/',
path: '/',
@@ -361,6 +373,7 @@ const RedirectTargetServerFnViaBeforeLoadRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
@@ -372,6 +385,7 @@ export interface FileRoutesByFullPath {
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
'/users': typeof UsersRouteWithChildren
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
@@ -417,12 +431,14 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/deferred': typeof DeferredRoute
'/inline-scripts': typeof InlineScriptsRoute
'/links': typeof LinksRoute
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
@@ -468,6 +484,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
@@ -480,6 +497,7 @@ export interface FileRoutesById {
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
'/users': typeof UsersRouteWithChildren
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
@@ -528,6 +546,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/canonical'
| '/not-found'
| '/search-params'
| '/specialChars'
@@ -539,6 +558,7 @@ export interface FileRouteTypes {
| '/scripts'
| '/stream'
| '/users'
+ | '/canonical/deep'
| '/specialChars/malformed'
| '/api/users'
| '/multi-cookie-redirect/target'
@@ -584,12 +604,14 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/canonical'
| '/specialChars'
| '/deferred'
| '/inline-scripts'
| '/links'
| '/scripts'
| '/stream'
+ | '/canonical/deep'
| '/specialChars/malformed'
| '/api/users'
| '/multi-cookie-redirect/target'
@@ -634,6 +656,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
+ | '/canonical'
| '/not-found'
| '/search-params'
| '/specialChars'
@@ -646,6 +669,7 @@ export interface FileRouteTypes {
| '/scripts'
| '/stream'
| '/users'
+ | '/canonical/deep'
| '/specialChars/malformed'
| '/_layout/_layout-2'
| '/api/users'
@@ -693,6 +717,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ CanonicalRouteRoute: typeof CanonicalRouteRouteWithChildren
NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren
SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren
SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren
@@ -801,6 +826,13 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof NotFoundRouteRouteImport
parentRoute: typeof rootRouteImport
}
+ '/canonical': {
+ id: '/canonical'
+ path: '/canonical'
+ fullPath: '/canonical'
+ preLoaderRoute: typeof CanonicalRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -1004,6 +1036,13 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof SpecialCharsMalformedRouteRouteImport
parentRoute: typeof SpecialCharsRouteRoute
}
+ '/canonical/deep': {
+ id: '/canonical/deep'
+ path: '/deep'
+ fullPath: '/canonical/deep'
+ preLoaderRoute: typeof CanonicalDeepRouteRouteImport
+ parentRoute: typeof CanonicalRouteRoute
+ }
'/redirect/$target/': {
id: '/redirect/$target/'
path: '/'
@@ -1112,6 +1151,18 @@ declare module '@tanstack/solid-router' {
}
}
+interface CanonicalRouteRouteChildren {
+ CanonicalDeepRouteRoute: typeof CanonicalDeepRouteRoute
+}
+
+const CanonicalRouteRouteChildren: CanonicalRouteRouteChildren = {
+ CanonicalDeepRouteRoute: CanonicalDeepRouteRoute,
+}
+
+const CanonicalRouteRouteWithChildren = CanonicalRouteRoute._addFileChildren(
+ CanonicalRouteRouteChildren,
+)
+
interface NotFoundRouteRouteChildren {
NotFoundViaBeforeLoadRoute: typeof NotFoundViaBeforeLoadRoute
NotFoundViaLoaderRoute: typeof NotFoundViaLoaderRoute
@@ -1292,6 +1343,7 @@ const RedirectTargetRouteWithChildren = RedirectTargetRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ CanonicalRouteRoute: CanonicalRouteRouteWithChildren,
NotFoundRouteRoute: NotFoundRouteRouteWithChildren,
SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren,
SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren,
diff --git a/e2e/solid-start/basic/src/routes/canonical/deep/route.tsx b/e2e/solid-start/basic/src/routes/canonical/deep/route.tsx
new file mode 100644
index 00000000000..bdeea7d4055
--- /dev/null
+++ b/e2e/solid-start/basic/src/routes/canonical/deep/route.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/canonical/deep')({
+ head: () => ({
+ links: [{ rel: 'canonical', href: 'https://example.com/canonical/deep' }],
+ }),
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return Hello "/canonical/deep"!
+}
diff --git a/e2e/solid-start/basic/src/routes/canonical/route.tsx b/e2e/solid-start/basic/src/routes/canonical/route.tsx
new file mode 100644
index 00000000000..c132eb1160d
--- /dev/null
+++ b/e2e/solid-start/basic/src/routes/canonical/route.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/canonical')({
+ head: () => ({
+ links: [{ rel: 'canonical', href: 'https://example.com/canonical' }],
+ }),
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return Hello "/canonical"!
+}
diff --git a/e2e/solid-start/basic/tests/canonical.spec.ts b/e2e/solid-start/basic/tests/canonical.spec.ts
new file mode 100644
index 00000000000..ff35bb0832d
--- /dev/null
+++ b/e2e/solid-start/basic/tests/canonical.spec.ts
@@ -0,0 +1,18 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+
+test('Deduplicates child canonical links over parent', async ({ page }) => {
+ await page.goto('/canonical/deep')
+ await page.waitForURL('/canonical/deep')
+
+ await expect(page.locator('link[rel="canonical"]')).toHaveCount(1)
+
+ // Get all canonical links
+ const links = await page.locator('link[rel="canonical"]').all()
+ expect(links).toHaveLength(1)
+
+ await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
+ 'href',
+ 'https://example.com/canonical/deep',
+ )
+})
diff --git a/e2e/vue-start/basic/src/routeTree.gen.ts b/e2e/vue-start/basic/src/routeTree.gen.ts
index 77a84e6ecaa..d6c329d14a6 100644
--- a/e2e/vue-start/basic/src/routeTree.gen.ts
+++ b/e2e/vue-start/basic/src/routeTree.gen.ts
@@ -21,6 +21,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as SpecialCharsRouteRouteImport } from './routes/specialChars/route'
import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route'
import { Route as NotFoundRouteRouteImport } from './routes/not-found/route'
+import { Route as CanonicalRouteRouteImport } from './routes/canonical/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as UsersIndexRouteImport } from './routes/users.index'
import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index'
@@ -50,6 +51,7 @@ import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-co
import { Route as ApiUsersRouteImport } from './routes/api/users'
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
import { Route as SpecialCharsMalformedRouteRouteImport } from './routes/specialChars/malformed/route'
+import { Route as CanonicalDeepRouteRouteImport } from './routes/canonical/deep/route'
import { Route as RedirectTargetIndexRouteImport } from './routes/redirect/$target/index'
import { Route as SpecialCharsMalformedSearchRouteImport } from './routes/specialChars/malformed/search'
import { Route as SpecialCharsMalformedParamRouteImport } from './routes/specialChars/malformed/$param'
@@ -123,6 +125,11 @@ const NotFoundRouteRoute = NotFoundRouteRouteImport.update({
path: '/not-found',
getParentRoute: () => rootRouteImport,
} as any)
+const CanonicalRouteRoute = CanonicalRouteRouteImport.update({
+ id: '/canonical',
+ path: '/canonical',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -272,6 +279,11 @@ const SpecialCharsMalformedRouteRoute =
path: '/malformed',
getParentRoute: () => SpecialCharsRouteRoute,
} as any)
+const CanonicalDeepRouteRoute = CanonicalDeepRouteRouteImport.update({
+ id: '/deep',
+ path: '/deep',
+ getParentRoute: () => CanonicalRouteRoute,
+} as any)
const RedirectTargetIndexRoute = RedirectTargetIndexRouteImport.update({
id: '/',
path: '/',
@@ -347,6 +359,7 @@ const RedirectTargetServerFnViaBeforeLoadRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
@@ -358,6 +371,7 @@ export interface FileRoutesByFullPath {
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
'/users': typeof UsersRouteWithChildren
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
@@ -401,12 +415,14 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/deferred': typeof DeferredRoute
'/inline-scripts': typeof InlineScriptsRoute
'/links': typeof LinksRoute
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
@@ -450,6 +466,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
@@ -462,6 +479,7 @@ export interface FileRoutesById {
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
'/users': typeof UsersRouteWithChildren
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
@@ -508,6 +526,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/canonical'
| '/not-found'
| '/search-params'
| '/specialChars'
@@ -519,6 +538,7 @@ export interface FileRouteTypes {
| '/scripts'
| '/stream'
| '/users'
+ | '/canonical/deep'
| '/specialChars/malformed'
| '/api/users'
| '/multi-cookie-redirect/target'
@@ -562,12 +582,14 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/canonical'
| '/specialChars'
| '/deferred'
| '/inline-scripts'
| '/links'
| '/scripts'
| '/stream'
+ | '/canonical/deep'
| '/specialChars/malformed'
| '/api/users'
| '/multi-cookie-redirect/target'
@@ -610,6 +632,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
+ | '/canonical'
| '/not-found'
| '/search-params'
| '/specialChars'
@@ -622,6 +645,7 @@ export interface FileRouteTypes {
| '/scripts'
| '/stream'
| '/users'
+ | '/canonical/deep'
| '/specialChars/malformed'
| '/_layout/_layout-2'
| '/api/users'
@@ -667,6 +691,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ CanonicalRouteRoute: typeof CanonicalRouteRouteWithChildren
NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren
SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren
SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren
@@ -773,6 +798,13 @@ declare module '@tanstack/vue-router' {
preLoaderRoute: typeof NotFoundRouteRouteImport
parentRoute: typeof rootRouteImport
}
+ '/canonical': {
+ id: '/canonical'
+ path: '/canonical'
+ fullPath: '/canonical'
+ preLoaderRoute: typeof CanonicalRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -976,6 +1008,13 @@ declare module '@tanstack/vue-router' {
preLoaderRoute: typeof SpecialCharsMalformedRouteRouteImport
parentRoute: typeof SpecialCharsRouteRoute
}
+ '/canonical/deep': {
+ id: '/canonical/deep'
+ path: '/deep'
+ fullPath: '/canonical/deep'
+ preLoaderRoute: typeof CanonicalDeepRouteRouteImport
+ parentRoute: typeof CanonicalRouteRoute
+ }
'/redirect/$target/': {
id: '/redirect/$target/'
path: '/'
@@ -1070,6 +1109,18 @@ declare module '@tanstack/vue-router' {
}
}
+interface CanonicalRouteRouteChildren {
+ CanonicalDeepRouteRoute: typeof CanonicalDeepRouteRoute
+}
+
+const CanonicalRouteRouteChildren: CanonicalRouteRouteChildren = {
+ CanonicalDeepRouteRoute: CanonicalDeepRouteRoute,
+}
+
+const CanonicalRouteRouteWithChildren = CanonicalRouteRoute._addFileChildren(
+ CanonicalRouteRouteChildren,
+)
+
interface NotFoundRouteRouteChildren {
NotFoundViaBeforeLoadRoute: typeof NotFoundViaBeforeLoadRoute
NotFoundViaLoaderRoute: typeof NotFoundViaLoaderRoute
@@ -1250,6 +1301,7 @@ const RedirectTargetRouteWithChildren = RedirectTargetRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ CanonicalRouteRoute: CanonicalRouteRouteWithChildren,
NotFoundRouteRoute: NotFoundRouteRouteWithChildren,
SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren,
SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren,
diff --git a/e2e/vue-start/basic/src/routes/canonical/deep/route.tsx b/e2e/vue-start/basic/src/routes/canonical/deep/route.tsx
new file mode 100644
index 00000000000..853e572966c
--- /dev/null
+++ b/e2e/vue-start/basic/src/routes/canonical/deep/route.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/vue-router'
+
+export const Route = createFileRoute('/canonical/deep')({
+ head: () => ({
+ links: [{ rel: 'canonical', href: 'https://example.com/canonical/deep' }],
+ }),
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return Hello "/canonical/deep"!
+}
diff --git a/e2e/vue-start/basic/src/routes/canonical/route.tsx b/e2e/vue-start/basic/src/routes/canonical/route.tsx
new file mode 100644
index 00000000000..874415f9604
--- /dev/null
+++ b/e2e/vue-start/basic/src/routes/canonical/route.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/vue-router'
+
+export const Route = createFileRoute('/canonical')({
+ head: () => ({
+ links: [{ rel: 'canonical', href: 'https://example.com/canonical' }],
+ }),
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return Hello "/canonical"!
+}
diff --git a/e2e/vue-start/basic/tests/canonical.spec.ts b/e2e/vue-start/basic/tests/canonical.spec.ts
new file mode 100644
index 00000000000..6c240988e44
--- /dev/null
+++ b/e2e/vue-start/basic/tests/canonical.spec.ts
@@ -0,0 +1,17 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+
+test('Deduplicates child canonical links over parent', async ({ page }) => {
+ await page.goto('/canonical/deep')
+ await page.waitForURL('/canonical/deep')
+
+ await expect(page.locator('link[rel="canonical"]')).toHaveCount(1)
+ // Get all canonical links
+ const links = await page.locator('link[rel="canonical"]').all()
+ expect(links).toHaveLength(1)
+
+ await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
+ 'href',
+ 'https://example.com/canonical/deep',
+ )
+})
diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx
index 1345eebb22d..bf70ff7ff55 100644
--- a/packages/react-router/src/headContentUtils.tsx
+++ b/packages/react-router/src/headContentUtils.tsx
@@ -90,17 +90,36 @@ export const useTags = () => {
const links = useRouterState({
select: (state) => {
- const constructed = state.matches
- .map((match) => match.links!)
- .filter(Boolean)
- .flat(1)
- .map((link) => ({
- tag: 'link',
- attrs: {
- ...link,
- nonce,
- },
- })) satisfies Array
+ const constructedLinks: Array = []
+ const relsToDedupe = new Set(['canonical'])
+ const linksByRel: Record = {}
+
+ for (let i = state.matches.length - 1; i >= 0; i--) {
+ const match = state.matches[i]!
+ const matchLinks = match.links
+ if (!matchLinks) continue
+
+ for (let j = matchLinks.length - 1; j >= 0; j--) {
+ const link = matchLinks[j]!
+ if (link.rel && relsToDedupe.has(link.rel)) {
+ if (linksByRel[link.rel]) {
+ continue
+ }
+ linksByRel[link.rel] = true
+ }
+
+ constructedLinks.push({
+ tag: 'link',
+ attrs: {
+ ...link,
+ nonce,
+ },
+ })
+ }
+ }
+
+ constructedLinks.reverse()
+ const constructed = constructedLinks satisfies Array
const manifest = router.ssr?.manifest
diff --git a/packages/solid-router/src/headContentUtils.tsx b/packages/solid-router/src/headContentUtils.tsx
index 1aaf927afae..cd88ba9fc80 100644
--- a/packages/solid-router/src/headContentUtils.tsx
+++ b/packages/solid-router/src/headContentUtils.tsx
@@ -91,17 +91,36 @@ export const useTags = () => {
const links = useRouterState({
select: (state) => {
- const constructed = state.matches
- .map((match) => match.links!)
- .filter(Boolean)
- .flat(1)
- .map((link) => ({
- tag: 'link',
- attrs: {
- ...link,
- nonce,
- },
- })) satisfies Array
+ const constructedLinks: Array = []
+ const relsToDedupe = new Set(['canonical'])
+ const linksByRel: Record = {}
+
+ for (let i = state.matches.length - 1; i >= 0; i--) {
+ const match = state.matches[i]!
+ const matchLinks = match.links
+ if (!matchLinks) continue
+
+ for (let j = matchLinks.length - 1; j >= 0; j--) {
+ const link = matchLinks[j]!
+ if (link.rel && relsToDedupe.has(link.rel)) {
+ if (linksByRel[link.rel]) {
+ continue
+ }
+ linksByRel[link.rel] = true
+ }
+
+ constructedLinks.push({
+ tag: 'link',
+ attrs: {
+ ...link,
+ nonce,
+ },
+ })
+ }
+ }
+
+ constructedLinks.reverse()
+ const constructed = constructedLinks satisfies Array
const manifest = router.ssr?.manifest
diff --git a/packages/vue-router/src/headContentUtils.tsx b/packages/vue-router/src/headContentUtils.tsx
index ae0fadbff1a..d628b95cd9b 100644
--- a/packages/vue-router/src/headContentUtils.tsx
+++ b/packages/vue-router/src/headContentUtils.tsx
@@ -74,17 +74,37 @@ export const useTags = () => {
})
const links = useRouterState({
- select: (state) =>
- state.matches
- .map((match) => match.links!)
- .filter(Boolean)
- .flat(1)
- .map((link) => ({
- tag: 'link',
- attrs: {
- ...link,
- },
- })) as Array,
+ select: (state) => {
+ const constructedLinks: Array = []
+ const relsToDedupe = new Set(['canonical'])
+ const linksByRel: Record = {}
+
+ for (let i = state.matches.length - 1; i >= 0; i--) {
+ const match = state.matches[i]!
+ const matchLinks = match.links
+ if (!matchLinks) continue
+
+ for (let j = matchLinks.length - 1; j >= 0; j--) {
+ const link = matchLinks[j]!
+ if (link.rel && relsToDedupe.has(link.rel)) {
+ if (linksByRel[link.rel]) {
+ continue
+ }
+ linksByRel[link.rel] = true
+ }
+
+ constructedLinks.push({
+ tag: 'link',
+ attrs: {
+ ...link,
+ },
+ })
+ }
+ }
+
+ constructedLinks.reverse()
+ return constructedLinks satisfies Array
+ },
})
const preloadMeta = useRouterState({