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:
| Stage | Value | With routePaths |
|---|---|---|
| The address bar | /pl/discover | /pl/odkrywaj |
What the route tree matches (rewrite.input) | /discover | /discover |
| What you write | to="/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, becauselocalizeUrlde-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/discoverlink 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 typedtorefuses it.
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.
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.
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 CatalogPageLink from a plugin page
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:
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:
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.
Plugin pages never import the host's route tree. HrefLink and navigate
work with any host, which saves future hosts from router spaghetti.