From 471592506940b2a2dde7d4c57a1c32e6834e436a Mon Sep 17 00:00:00 2001
From: sanjibani <18418553+sanjibani@users.noreply.github.com>
Date: Tue, 23 Jun 2026 18:35:45 +0530
Subject: [PATCH] feat(link): accept external URLs in Link `to` prop
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The runtime `useLinkProps` already detects absolute URLs in the `to`
prop (it parses `to` with `new URL()` and renders it as an anchor's
`href` when it looks external), but the type-level constraint from
`ToPathOption` rejected any string with a scheme — so users got a
TypeScript error for valid runtime usage:
// TS error
// TS error
// TS error
Widen `ToPathOption` to also accept the four protocols in the default
protocol allowlist (http:, https:, mailto:, tel:). Internal route paths
(`/dashboard`, `../profile`) continue to type-check unchanged — the
union only widens what is accepted.
Expose the new `ExternalUrl` template-literal type from router-core so
apps that want to type their own Link-like components can reuse it.
Fixes #4901
---
packages/router-core/src/index.ts | 1 +
packages/router-core/src/link.ts | 37 +++++++++++----
.../router-core/tests/external-url.test-d.ts | 45 +++++++++++++++++++
3 files changed, 75 insertions(+), 8 deletions(-)
create mode 100644 packages/router-core/tests/external-url.test-d.ts
diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts
index fd673ca410..21b1c15f07 100644
--- a/packages/router-core/src/index.ts
+++ b/packages/router-core/src/index.ts
@@ -30,6 +30,7 @@ export type {
SearchParamOptions,
PathParamOptions,
ToPathOption,
+ ExternalUrl,
LinkOptions,
MakeOptionalPathParams,
FromPathOption,
diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts
index 55d5a79ce8..edcd9d633b 100644
--- a/packages/router-core/src/link.ts
+++ b/packages/router-core/src/link.ts
@@ -613,18 +613,39 @@ export type PathParamOptions =
? MakeOptionalPathParams
: MakeRequiredPathParams
+/**
+ * Absolute external URL strings accepted by the `to` prop on `Link`,
+ * `createLink`, and `linkOptions`. Matches the runtime `new URL(to)` check in
+ * `useLinkProps` and the default protocol allowlist (`http:`, `https:`,
+ * `mailto:`, `tel:`). When `to` matches one of these patterns the runtime
+ * short-circuits internal-route handling and renders the value as an
+ * anchor's `href` (still applying `isDangerousProtocol` filtering).
+ *
+ * Internal route paths (e.g. `/dashboard`, `../profile`) remain valid; this
+ * type only widens what is accepted, never narrows it.
+ *
+ * @see https://github.com/TanStack/router/issues/4901
+ */
+export type ExternalUrl =
+ | `http://${string}`
+ | `https://${string}`
+ | `mailto:${string}`
+ | `tel:${string}`
+
export type ToPathOption<
TRouter extends AnyRouter = AnyRouter,
TFrom extends string = string,
TTo extends string | undefined = string,
-> = ConstrainLiteral<
- TTo,
- RelativeToPathAutoComplete<
- TRouter,
- NoInfer extends string ? NoInfer : '',
- NoInfer & string
- >
->
+> =
+ | ConstrainLiteral<
+ TTo,
+ RelativeToPathAutoComplete<
+ TRouter,
+ NoInfer extends string ? NoInfer : '',
+ NoInfer & string
+ >
+ >
+ | ExternalUrl
export type FromPathOption = ConstrainLiteral<
TFrom,
diff --git a/packages/router-core/tests/external-url.test-d.ts b/packages/router-core/tests/external-url.test-d.ts
new file mode 100644
index 0000000000..8591fa730e
--- /dev/null
+++ b/packages/router-core/tests/external-url.test-d.ts
@@ -0,0 +1,45 @@
+import { describe, expectTypeOf, test } from 'vitest'
+import type { ExternalUrl, ToPathOption } from '../src/link'
+
+// Regression coverage for https://github.com/TanStack/router/issues/4901.
+// `to` on `Link`/`createLink`/`linkOptions` should accept absolute external
+// URLs (https, mailto, tel, http) in addition to internal route paths.
+describe('ExternalUrl', () => {
+ test('matches https URLs', () => {
+ expectTypeOf<'https://example.com'>().toMatchTypeOf()
+ expectTypeOf<'https://example.com/path?query=1#hash'>().toMatchTypeOf()
+ })
+
+ test('matches http URLs', () => {
+ expectTypeOf<'http://example.com'>().toMatchTypeOf()
+ })
+
+ test('matches mailto: URLs', () => {
+ expectTypeOf<'mailto:user@example.com'>().toMatchTypeOf()
+ })
+
+ test('matches tel: URLs', () => {
+ expectTypeOf<'tel:+15551234567'>().toMatchTypeOf()
+ })
+
+ test('rejects internal route paths (no scheme)', () => {
+ // Without a scheme these are NOT external URLs.
+ expectTypeOf<'/dashboard'>().not.toMatchTypeOf()
+ expectTypeOf<'../profile'>().not.toMatchTypeOf()
+ })
+})
+
+describe('ToPathOption', () => {
+ test('accepts absolute external URLs', () => {
+ // Default generics: no specific router/type constraints.
+ // `to` should now accept any ExternalUrl.
+ expectTypeOf<'https://example.com'>().toMatchTypeOf()
+ expectTypeOf<'mailto:user@example.com'>().toMatchTypeOf()
+ expectTypeOf<'tel:+15551234567'>().toMatchTypeOf()
+ })
+
+ test('still accepts internal route paths', () => {
+ // Backward compat: internal paths must remain assignable to `to`.
+ expectTypeOf().toMatchTypeOf()
+ })
+})
\ No newline at end of file