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 on hover. You write the logical path; the router writes the language.

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. 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:

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

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

Plugin route modules

A plugin route should not import the host router. Use its navigate prop for same-page filters, sort order, and pagination. For links to another internal screen, let the host render a link component; a plain <a> is for another origin only.

Keep plugins portable

navigate only changes this plugin page's query string. That small boundary is intentional—and saves future hosts from router spaghetti.

Next