Documentation

Routing

Navigation

Link between pages with the router's own Link, let VitNode write the locale prefix, and navigate from code after a form submits.

Inside the app, use TanStack Router's Link. It builds the href, applies the locale prefix, and preloads the destination once the pointer rests on it for 75 ms. You write the logical path; the router writes the language. Preloading covers what a preload costs and how to turn it off.

Example

import { Link } from '@tanstack/react-router'

const DiscoverLink = () => <Link to="/discover">Discover</Link>

That renders /discover for an English reader and /pl/discover for a Polish one - or /pl/odkrywaj, once the app translates the path. Same component, same to, no branch and no useLocale() call.

One route, two URLs

/discover and /pl/discover are one route. The router's rewrite is what makes that work, and it runs in both directions:

StageValueWith routePaths
The address bar/pl/discover/pl/odkrywaj
What the route tree matches (rewrite.input)/discover/discover
What you writeto="/discover"to="/discover"
What React renders (rewrite.output)/pl/discover/pl/odkrywaj

A translated path is spelled by the same rewrite, so route files, route IDs and every to stay English. With i18n.domains, the domain already says the language, so on vitnode.pl the same link renders /odkrywaj.

input is why no route file anywhere in the app mentions a locale - there is no routes/pl/ directory and there is not going to be one. output reads the locale off the router's own current location rather than off window, so the href rendered during SSR is byte-identical to the one rendered after hydration.

So write the logical path. Two things that look reasonable and are not:

  • Building the prefix yourself. to={`/${locale}/discover`} does not double up, because localizeUrl de-localizes before it prefixes and is therefore idempotent. What it does instead is discard the prefix you wrote and re-apply the reader's current locale, so an English reader following your /pl/discover link lands on /discover. Not honoured; overwritten.
  • Naming a prefixed route. to="/pl/discover" is not a path in the route tree, so the router's typed to refuses it.
`/admin` and `/api` carry no prefix at all

Both sit outside the localized URL space, so nothing is stripped from them and nothing is added. /pl/admin/core is therefore a mistake rather than a Polish page, and the app's request middleware 308-redirects it to /admin/core while storing pl in the language cookie - so the AdminCP still renders in the language the visitor just asked for. The same middleware canonicalises /en/discover to /discover, because the default locale is unprefixed and two indexable URLs for one page is one too many.

An unknown prefix is a 404, not a fallback

Only a prefix the app would itself emit gets stripped, so /xx/discover reaches the route tree intact and matches nothing. That is deliberate: a silent fallback would serve the same page at infinitely many URLs.

Update query state from a plugin page

Plugin route props expose a narrow navigate function for filters, sorting, and pagination on the page already being viewed. It keeps the plugin independent of the host router’s entire route tree.

plugins/catalog/src/pages/catalog-page.tsx
import type { PluginRoutePageProps } from '@vitnode/core/routing'

interface CatalogSearch {
  page: number
}

const CatalogPage = ({
  navigate,
  search,
}: PluginRoutePageProps<undefined, CatalogSearch>) => (
  <button
    onClick={() =>
      void navigate({
        resetScroll: false,
        search: { page: search.page + 1 }, 
      })
    }
    type="button"
  >
    Next page
  </button>
)

export default CatalogPage

A plugin page cannot use the router's typed to, because the host's route tree does not know plugin URLs at compile time. Use HrefLink from @vitnode/core/tanstack/link instead. It takes the same href string you would put on an <a>, query and hash included, and renders a router link:

plugins/catalog/src/pages/catalog-list.tsx
import { HrefLink } from '@vitnode/core/tanstack/link'

export const CatalogItemLink = ({ slug }: { slug: string }) => (
  <HrefLink href={`/catalog/${encodeURIComponent(slug)}?tab=reviews`}>
    Reviews
  </HrefLink>
)

The link navigates without a full page load, preloads after a 75 ms hover, and gets the visitor's locale prefix from the router. Write the href without a locale prefix. An absolute URL such as https://example.com renders a plain <a>. aria-current="page" is set only when the href matches the current URL exactly, query and hash included.

To navigate from code after a form is saved, use useHrefNavigate:

plugins/catalog/src/pages/catalog-new-page.tsx
import { useHrefNavigate } from '@vitnode/core/tanstack/link'

const navigate = useHrefNavigate()

// after a successful save:
await navigate(`/catalog/${encodeURIComponent(item.slug)}`)

Invalidate the queries the save changed before you navigate, so the next page's loader reads fresh data. For filters, sorting and pagination on the page already being viewed, use the page's own navigate prop shown above.

Keep plugins portable

Plugin pages never import the host's route tree. HrefLink and navigate work with any host, which saves future hosts from router spaghetti.

Next