@tanstack/react-router 1.153.1 → 1.154.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2759,6 +2759,8 @@ paraglideVitePlugin({
2759
2759
 
2760
2760
  ### URL Localization via Router Rewrite
2761
2761
 
2762
+ The router's \`rewrite\` option enables bidirectional URL transformation, perfect for locale prefixes. For comprehensive documentation on URL rewrites including advanced patterns, see the [URL Rewrites guide](./url-rewrites.md).
2763
+
2762
2764
  \`\`\`ts
2763
2765
  import { deLocalizeUrl, localizeUrl } from './paraglide/runtime'
2764
2766
 
@@ -3529,6 +3531,16 @@ const link = (
3529
3531
  )
3530
3532
  \`\`\`
3531
3533
 
3534
+ > ⚠️ When directly navigating to a URL with a hash fragment, the fragment is only available on the client; the browser does not send the fragment to the server as part of the request URL.
3535
+ >
3536
+ > This means that if you are using a server-side rendering approach, the hash fragment will not be available on the server-side, and hydration mismatches can occur when using the hash for rendering markup.
3537
+ >
3538
+ > Examples of this would be:
3539
+ >
3540
+ > - returning the hash value in the markup,
3541
+ > - conditional rendering based on the hash value, or
3542
+ > - setting the Link as active based on the hash value.
3543
+
3532
3544
  ### Navigating with Optional Parameters
3533
3545
 
3534
3546
  Optional path parameters provide flexible navigation patterns where you can include or omit parameters as needed. Optional parameters use the \`{-$paramName}\` syntax and offer fine-grained control over URL structure.
@@ -7581,4 +7593,499 @@ const { enable, disable, navigate } = useConditionalNavigate({
7581
7593
  })
7582
7594
  \`\`\`
7583
7595
 
7596
+ # URL Rewrites
7597
+
7598
+ URL rewrites allow you to transform URLs bidirectionally between what the browser displays and what the router interprets internally. This powerful feature enables patterns like locale prefixes, subdomain routing, legacy URL migration, and multi-tenant applications without duplicating routes or complicating your route tree.
7599
+
7600
+ ## When to Use URL Rewrites
7601
+
7602
+ URL rewrites are useful when you need to:
7603
+
7604
+ - **i18n locale prefixes**: Display \`/en/about\` in the browser but route to \`/about\` internally
7605
+ - **Subdomain routing**: Route \`admin.example.com/users\` to \`/admin/users\` internally
7606
+ - **Legacy URL migration**: Support old URLs like \`/old-path\` that map to new routes
7607
+ - **Multi-tenant applications**: Route \`tenant1.example.com\` to tenant-specific routes
7608
+ - **Custom URL schemes**: Transform any URL pattern to match your route structure
7609
+
7610
+ ## How URL Rewrites Work
7611
+
7612
+ URL rewrites operate in two directions:
7613
+
7614
+ 1. **Input rewrite**: Transforms the URL **from the browser** before the router interprets it
7615
+ 2. **Output rewrite**: Transforms the URL **from the router** before it's written to the browser
7616
+
7617
+ \`\`\`
7618
+ ┌─────────────────────────────────────────────────────────────────┐
7619
+ │ Browser URL Bar │
7620
+ │ /en/about?q=test │
7621
+ └─────────────────────────┬───────────────────────────────────────┘
7622
+
7623
+ ▼ input rewrite
7624
+ ┌─────────────────────────────────────────────────────────────────┐
7625
+ │ Router Internal URL │
7626
+ │ /about?q=test │
7627
+ │ │
7628
+ │ (matches routes, runs loaders) │
7629
+ └─────────────────────────┬───────────────────────────────────────┘
7630
+
7631
+ ▼ output rewrite
7632
+ ┌─────────────────────────────────────────────────────────────────┐
7633
+ │ Browser URL Bar │
7634
+ │ /en/about?q=test │
7635
+ └─────────────────────────────────────────────────────────────────┘
7636
+ \`\`\`
7637
+
7638
+ The router exposes two href properties on the location object:
7639
+
7640
+ - \`location.href\` - The internal URL (after input rewrite)
7641
+ - \`location.publicHref\` - The external URL displayed in the browser (after output rewrite)
7642
+
7643
+ ## Basic Usage
7644
+
7645
+ Configure rewrites when creating your router:
7646
+
7647
+ \`\`\`tsx
7648
+ import { createRouter } from '@tanstack/react-router'
7649
+
7650
+ const router = createRouter({
7651
+ routeTree,
7652
+ rewrite: {
7653
+ input: ({ url }) => {
7654
+ // Transform browser URL → router internal URL
7655
+ // Return the modified URL, a new URL, or undefined to skip
7656
+ return url
7657
+ },
7658
+ output: ({ url }) => {
7659
+ // Transform router internal URL → browser URL
7660
+ // Return the modified URL, a new URL, or undefined to skip
7661
+ return url
7662
+ },
7663
+ },
7664
+ })
7665
+ \`\`\`
7666
+
7667
+ The \`input\` and \`output\` functions receive a \`URL\` object and can:
7668
+
7669
+ - Mutate and return the same \`url\` object
7670
+ - Return a new \`URL\` instance
7671
+ - Return a full href string (will be parsed into a URL)
7672
+ - Return \`undefined\` to skip the rewrite
7673
+
7674
+ ## Common Patterns
7675
+
7676
+ ### Pattern 1: i18n Locale Prefix
7677
+
7678
+ Strip locale prefixes on input and add them back on output:
7679
+
7680
+ \`\`\`tsx
7681
+ const locales = ['en', 'fr', 'es', 'de']
7682
+ const defaultLocale = 'en'
7683
+
7684
+ // Get current locale (from cookie, localStorage, or detection)
7685
+ function getLocale() {
7686
+ return localStorage.getItem('locale') || defaultLocale
7687
+ }
7688
+
7689
+ const router = createRouter({
7690
+ routeTree,
7691
+ rewrite: {
7692
+ input: ({ url }) => {
7693
+ // Check if pathname starts with a locale prefix
7694
+ const segments = url.pathname.split('/').filter(Boolean)
7695
+ const firstSegment = segments[0]
7696
+
7697
+ if (firstSegment && locales.includes(firstSegment)) {
7698
+ // Strip the locale prefix: /en/about → /about
7699
+ url.pathname = '/' + segments.slice(1).join('/') || '/'
7700
+ }
7701
+ return url
7702
+ },
7703
+ output: ({ url }) => {
7704
+ const locale = getLocale()
7705
+ // Add locale prefix: /about → /en/about
7706
+ if (locale !== defaultLocale || true) {
7707
+ // Always prefix, or conditionally skip default locale
7708
+ url.pathname = \`/\${locale}\${url.pathname === '/' ? '' : url.pathname}\`
7709
+ }
7710
+ return url
7711
+ },
7712
+ },
7713
+ })
7714
+ \`\`\`
7715
+
7716
+ For production i18n, consider using a library like Paraglide that provides \`localizeUrl\` and \`deLocalizeUrl\` functions. See the [Internationalization guide](./internationalization-i18n.md) for integration details.
7717
+
7718
+ ### Pattern 2: Subdomain to Path Routing
7719
+
7720
+ Route subdomain requests to path-based routes:
7721
+
7722
+ \`\`\`tsx
7723
+ const router = createRouter({
7724
+ routeTree,
7725
+ rewrite: {
7726
+ input: ({ url }) => {
7727
+ const subdomain = url.hostname.split('.')[0]
7728
+
7729
+ // admin.example.com/users → /admin/users
7730
+ if (subdomain === 'admin') {
7731
+ url.pathname = '/admin' + url.pathname
7732
+ }
7733
+ // api.example.com/v1/users → /api/v1/users
7734
+ else if (subdomain === 'api') {
7735
+ url.pathname = '/api' + url.pathname
7736
+ }
7737
+
7738
+ return url
7739
+ },
7740
+ output: ({ url }) => {
7741
+ // Reverse the transformation for link generation
7742
+ if (url.pathname.startsWith('/admin')) {
7743
+ url.hostname = 'admin.example.com'
7744
+ url.pathname = url.pathname.replace(/^\/admin/, '') || '/'
7745
+ } else if (url.pathname.startsWith('/api')) {
7746
+ url.hostname = 'api.example.com'
7747
+ url.pathname = url.pathname.replace(/^\/api/, '') || '/'
7748
+ }
7749
+ return url
7750
+ },
7751
+ },
7752
+ })
7753
+ \`\`\`
7754
+
7755
+ ### Pattern 3: Legacy URL Migration
7756
+
7757
+ Support old URLs while maintaining new route structure:
7758
+
7759
+ \`\`\`tsx
7760
+ const legacyPaths: Record<string, string> = {
7761
+ '/old-about': '/about',
7762
+ '/old-contact': '/contact',
7763
+ '/blog-posts': '/blog',
7764
+ '/user-profile': '/account/profile',
7765
+ }
7766
+
7767
+ const router = createRouter({
7768
+ routeTree,
7769
+ rewrite: {
7770
+ input: ({ url }) => {
7771
+ const newPath = legacyPaths[url.pathname]
7772
+ if (newPath) {
7773
+ url.pathname = newPath
7774
+ }
7775
+ return url
7776
+ },
7777
+ // No output rewrite needed - new URLs will be used going forward
7778
+ },
7779
+ })
7780
+ \`\`\`
7781
+
7782
+ ### Pattern 4: Multi-tenant Routing
7783
+
7784
+ Route tenant-specific domains to a unified route structure:
7785
+
7786
+ \`\`\`tsx
7787
+ const router = createRouter({
7788
+ routeTree,
7789
+ rewrite: {
7790
+ input: ({ url }) => {
7791
+ // Extract tenant from subdomain: acme.app.com → acme
7792
+ const parts = url.hostname.split('.')
7793
+ if (parts.length >= 3) {
7794
+ const tenant = parts[0]
7795
+ // Inject tenant into the path: /dashboard → /tenant/acme/dashboard
7796
+ url.pathname = \`/tenant/\${tenant}\${url.pathname}\`
7797
+ }
7798
+ return url
7799
+ },
7800
+ output: ({ url }) => {
7801
+ // Extract tenant from path and move to subdomain
7802
+ const match = url.pathname.match(/^\/tenant\/([^/]+)(.*)$/)
7803
+ if (match) {
7804
+ const [, tenant, rest] = match
7805
+ url.hostname = \`\${tenant}.app.com\`
7806
+ url.pathname = rest || '/'
7807
+ }
7808
+ return url
7809
+ },
7810
+ },
7811
+ })
7812
+ \`\`\`
7813
+
7814
+ ### Pattern 5: Search Parameter Transformation
7815
+
7816
+ Transform search parameters during rewrites:
7817
+
7818
+ \`\`\`tsx
7819
+ const router = createRouter({
7820
+ routeTree,
7821
+ rewrite: {
7822
+ input: ({ url }) => {
7823
+ // Convert legacy search param format
7824
+ // ?filter_status=active → ?status=active
7825
+ const filterStatus = url.searchParams.get('filter_status')
7826
+ if (filterStatus) {
7827
+ url.searchParams.delete('filter_status')
7828
+ url.searchParams.set('status', filterStatus)
7829
+ }
7830
+ return url
7831
+ },
7832
+ output: ({ url }) => {
7833
+ // Optionally transform back for external display
7834
+ return url
7835
+ },
7836
+ },
7837
+ })
7838
+ \`\`\`
7839
+
7840
+ ## Composing Multiple Rewrites
7841
+
7842
+ When you need multiple independent rewrite transformations, use \`composeRewrites\` to combine them:
7843
+
7844
+ \`\`\`tsx
7845
+ import { composeRewrites } from '@tanstack/react-router'
7846
+
7847
+ const localeRewrite = {
7848
+ input: ({ url }) => {
7849
+ // Strip locale prefix
7850
+ const match = url.pathname.match(/^\/(en|fr|es)(\/.*)$/)
7851
+ if (match) {
7852
+ url.pathname = match[2] || '/'
7853
+ }
7854
+ return url
7855
+ },
7856
+ output: ({ url }) => {
7857
+ // Add locale prefix
7858
+ url.pathname = \`/en\${url.pathname === '/' ? '' : url.pathname}\`
7859
+ return url
7860
+ },
7861
+ }
7862
+
7863
+ const legacyRewrite = {
7864
+ input: ({ url }) => {
7865
+ if (url.pathname === '/old-page') {
7866
+ url.pathname = '/new-page'
7867
+ }
7868
+ return url
7869
+ },
7870
+ }
7871
+
7872
+ const router = createRouter({
7873
+ routeTree,
7874
+ rewrite: composeRewrites([localeRewrite, legacyRewrite]),
7875
+ })
7876
+ \`\`\`
7877
+
7878
+ **Order of operations:**
7879
+
7880
+ - **Input rewrites**: Execute in order (first to last)
7881
+ - **Output rewrites**: Execute in reverse order (last to first)
7882
+
7883
+ This ensures that composed rewrites "unwrap" correctly. In the example above:
7884
+
7885
+ - Input: locale strips \`/en\`, then legacy redirects \`/old-page\`
7886
+ - Output: legacy runs first (no-op), then locale adds \`/en\` back
7887
+
7888
+ ## Interaction with Basepath
7889
+
7890
+ When you configure a \`basepath\`, the router internally implements it as a rewrite. If you also provide a custom \`rewrite\`, they are automatically composed together:
7891
+
7892
+ \`\`\`tsx
7893
+ const router = createRouter({
7894
+ routeTree,
7895
+ basepath: '/app',
7896
+ rewrite: {
7897
+ input: ({ url }) => {
7898
+ // This runs AFTER basepath is stripped
7899
+ // Browser: /app/en/about → After basepath: /en/about → Your rewrite: /about
7900
+ return url
7901
+ },
7902
+ output: ({ url }) => {
7903
+ // This runs BEFORE basepath is added
7904
+ // Your rewrite: /about → After your rewrite: /en/about → Basepath adds: /app/en/about
7905
+ return url
7906
+ },
7907
+ },
7908
+ })
7909
+ \`\`\`
7910
+
7911
+ The composition order ensures:
7912
+
7913
+ 1. **Input**: Basepath stripped first, then your rewrite runs
7914
+ 2. **Output**: Your rewrite runs first, then basepath added
7915
+
7916
+ ## Working with Links and Navigation
7917
+
7918
+ ### Link Component
7919
+
7920
+ The \`<Link>\` component automatically applies output rewrites when generating \`href\` attributes:
7921
+
7922
+ \`\`\`tsx
7923
+ // With locale rewrite configured (adds /en prefix)
7924
+ <Link to="/about">About</Link>
7925
+ // Renders: <a href="/en/about">About</a>
7926
+ \`\`\`
7927
+
7928
+ ### Programmatic Navigation
7929
+
7930
+ Programmatic navigation via \`navigate()\` or \`router.navigate()\` also respects rewrites:
7931
+
7932
+ \`\`\`tsx
7933
+ const navigate = useNavigate()
7934
+
7935
+ // Navigates to /about internally, displays /en/about in browser
7936
+ navigate({ to: '/about' })
7937
+ \`\`\`
7938
+
7939
+ ### Hard Links for Cross-Origin Rewrites
7940
+
7941
+ When an output rewrite changes the origin (hostname), the \`<Link>\` component automatically renders a standard anchor tag instead of using client-side navigation:
7942
+
7943
+ \`\`\`tsx
7944
+ // Rewrite that changes hostname for /admin paths
7945
+ const router = createRouter({
7946
+ routeTree,
7947
+ rewrite: {
7948
+ output: ({ url }) => {
7949
+ if (url.pathname.startsWith('/admin')) {
7950
+ url.hostname = 'admin.example.com'
7951
+ url.pathname = url.pathname.replace(/^\/admin/, '') || '/'
7952
+ }
7953
+ return url
7954
+ },
7955
+ },
7956
+ })
7957
+
7958
+ // This link will be a hard navigation (full page load)
7959
+ <Link to="/admin/dashboard">Admin Dashboard</Link>
7960
+ // Renders: <a href="https://admin.example.com/dashboard">Admin Dashboard</a>
7961
+ \`\`\`
7962
+
7963
+ ## The publicHref Property
7964
+
7965
+ The router's location object includes a \`publicHref\` property that contains the external URL (after output rewrite):
7966
+
7967
+ \`\`\`tsx
7968
+ function MyComponent() {
7969
+ const location = useLocation()
7970
+
7971
+ // Internal URL used for routing
7972
+ console.log(location.href) // "/about"
7973
+
7974
+ // External URL shown in browser
7975
+ console.log(location.publicHref) // "/en/about"
7976
+
7977
+ return (
7978
+ <div>
7979
+ {/* Use publicHref for sharing, canonical URLs, etc. */}
7980
+ <ShareButton url={window.location.origin + location.publicHref} />
7981
+ </div>
7982
+ )
7983
+ }
7984
+ \`\`\`
7985
+
7986
+ Use \`publicHref\` when you need the actual browser URL for:
7987
+
7988
+ - Social sharing
7989
+ - Canonical URLs
7990
+ - Analytics tracking
7991
+ - Copying links to clipboard
7992
+
7993
+ ## Server-side Considerations
7994
+
7995
+ URL rewrites apply on both client and server. When using TanStack Start:
7996
+
7997
+ ### Server Middleware
7998
+
7999
+ Rewrites are applied when parsing incoming requests:
8000
+
8001
+ \`\`\`tsx
8002
+ // router.tsx
8003
+ export const router = createRouter({
8004
+ routeTree,
8005
+ rewrite: {
8006
+ input: ({ url }) => deLocalizeUrl(url),
8007
+ output: ({ url }) => localizeUrl(url),
8008
+ },
8009
+ })
8010
+ \`\`\`
8011
+
8012
+ The server handler will use the same rewrite configuration to parse incoming URLs and generate responses with the correct external URLs.
8013
+
8014
+ ### SSR Hydration
8015
+
8016
+ The router ensures that the server-rendered HTML and client hydration use consistent URLs. The \`publicHref\` is serialized during SSR so the client can hydrate with the correct external URL.
8017
+
8018
+ ## API Reference
8019
+
8020
+ ### \`rewrite\` option
8021
+
8022
+ - Type: [\`LocationRewrite\`](#locationrewrite-type)
8023
+ - Optional
8024
+ - Configures bidirectional URL transformation between browser and router.
8025
+
8026
+ ### LocationRewrite type
8027
+
8028
+ \`\`\`tsx
8029
+ type LocationRewrite = {
8030
+ /**
8031
+ * Transform the URL before the router interprets it.
8032
+ * Called when reading from browser history.
8033
+ */
8034
+ input?: LocationRewriteFunction
8035
+
8036
+ /**
8037
+ * Transform the URL before it's written to browser history.
8038
+ * Called when generating links and committing navigation.
8039
+ */
8040
+ output?: LocationRewriteFunction
8041
+ }
8042
+ \`\`\`
8043
+
8044
+ ### LocationRewriteFunction type
8045
+
8046
+ \`\`\`tsx
8047
+ type LocationRewriteFunction = (opts: { url: URL }) => undefined | string | URL
8048
+ \`\`\`
8049
+
8050
+ **Parameters:**
8051
+
8052
+ - \`url\`: A \`URL\` object representing the current URL
8053
+
8054
+ **Returns:**
8055
+
8056
+ - \`URL\`: The transformed URL object (can be the same mutated object or a new instance)
8057
+ - \`string\`: A full href string that will be parsed into a URL
8058
+ - \`undefined\`: Skip the rewrite, use the original URL
8059
+
8060
+ ### composeRewrites function
8061
+
8062
+ \`\`\`tsx
8063
+ import { composeRewrites } from '@tanstack/react-router'
8064
+
8065
+ function composeRewrites(rewrites: Array<LocationRewrite>): LocationRewrite
8066
+ \`\`\`
8067
+
8068
+ Combines multiple rewrite pairs into a single rewrite. Input rewrites execute in order, output rewrites execute in reverse order.
8069
+
8070
+ **Example:**
8071
+
8072
+ \`\`\`tsx
8073
+ const composedRewrite = composeRewrites([
8074
+ { input: rewrite1Input, output: rewrite1Output },
8075
+ { input: rewrite2Input, output: rewrite2Output },
8076
+ ])
8077
+
8078
+ // Input execution order: rewrite1Input → rewrite2Input
8079
+ // Output execution order: rewrite2Output → rewrite1Output
8080
+ \`\`\`
8081
+
8082
+ ## Examples
8083
+
8084
+ Complete working examples are available in the TanStack Router repository:
8085
+
8086
+ - [React + Paraglide (Client-side i18n)](https://github.com/TanStack/router/tree/main/examples/react/i18n-paraglide)
8087
+ - [React + TanStack Start + Paraglide (SSR i18n)](https://github.com/TanStack/router/tree/main/examples/react/start-i18n-paraglide)
8088
+ - [Solid + Paraglide (Client-side i18n)](https://github.com/TanStack/router/tree/main/examples/solid/i18n-paraglide)
8089
+ - [Solid + TanStack Start + Paraglide (SSR i18n)](https://github.com/TanStack/router/tree/main/examples/solid/start-i18n-paraglide)
8090
+
7584
8091
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/react-router",
3
- "version": "1.153.1",
3
+ "version": "1.154.1",
4
4
  "description": "Modern and scalable routing for React applications",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -81,8 +81,8 @@
81
81
  "isbot": "^5.1.22",
82
82
  "tiny-invariant": "^1.3.3",
83
83
  "tiny-warning": "^1.0.3",
84
- "@tanstack/history": "1.151.1",
85
- "@tanstack/router-core": "1.153.1"
84
+ "@tanstack/history": "1.153.2",
85
+ "@tanstack/router-core": "1.154.1"
86
86
  },
87
87
  "devDependencies": {
88
88
  "@testing-library/jest-dom": "^6.6.3",
@@ -7,6 +7,7 @@ import {
7
7
  import { useLayoutEffect, usePrevious } from './utils'
8
8
  import { useRouter } from './useRouter'
9
9
  import { useRouterState } from './useRouterState'
10
+ import type { SubscriberArgs } from '@tanstack/history'
10
11
 
11
12
  export function Transitioner() {
12
13
  const router = useRouter()
@@ -41,7 +42,17 @@ export function Transitioner() {
41
42
  // Subscribe to location changes
42
43
  // and try to load the new location
43
44
  React.useEffect(() => {
44
- const unsub = router.history.subscribe(router.load)
45
+ const unsub = router.history.subscribe(
46
+ ({ navigateOpts }: SubscriberArgs) => {
47
+ // If commitLocation initiated this navigation, it handles load() itself
48
+ if (navigateOpts?.skipTransitionerLoad) {
49
+ return
50
+ }
51
+
52
+ // External navigation (pop, direct history.push, etc): call load normally
53
+ router.load()
54
+ },
55
+ )
45
56
 
46
57
  const nextLocation = router.buildLocation({
47
58
  to: router.latestLocation.pathname,