Link & Navigation
Furin renders real anchors on the server and upgrades same-origin navigation in the browser. The result keeps standard browser behavior—open in a new tab, copy link, modifier clicks—while avoiding a document reload for application routes.
<Link>
import { Link } from "@teyik0/furin/link";
<Link to="/docs">Documentation</Link>;
<Link to="/blog/:slug" params={{ slug: "hello-furin" }}>
Read post
</Link>;
<Link to="/search" search={{ q: "bun", page: 2 }} hash="results">
Search
</Link>;
Generated route patterns autocomplete in to; dynamic patterns require their typed params. Concrete paths such as /blog/hello-furin are accepted when they match a route pattern, and static routes take precedence over dynamic siblings. Values are validated again by the destination route's TypeBox schemas on the server.
Destination checking
Once furin-env.d.ts is generated, <Link to>, navigate(), router.navigate(), router.prefetch() and useSearch() all apply the same rules to string literals:
| Destination | Accepted |
|---|---|
Route pattern: "/blog/:slug" | Yes, with its params |
Path matching a pattern: "/blog/hello-furin", "/search?q=bun" | Yes |
Absolute URL: "https://bun.sh", "mailto:team@example.com", "//cdn.example.com/a.pdf" | Yes, except in useSearch() |
Unknown internal path: "/blgo/hello" | No. The compile error names the route |
A value typed as plain string, such as an href loaded from a CMS, can't be checked statically and is accepted as-is. TanStack Router uses the same escape hatch. Use it intentionally (to={href}, or "/legacy" as string). Furin still renders its 404 boundary for an unknown path at runtime. Before furin-env.d.ts exists, every string is accepted, so a new project can type-check.
Props
| Prop | Purpose |
|---|---|
to | Route pattern, a path matching one, or an absolute URL (see destination checking) |
params | Replaces typed :param segments |
search | Typed navigation input: required when the query schema has a required field; schema defaults are omitted from the URL (input vs output) |
hash | Fragment without # |
replace | Replaces the current history entry |
resetScroll | Scrolls to the hash target or top after navigation; defaults to true |
preload | false, intent, viewport, or render |
preloadDelay | Hover delay for intent preloading; defaults to 50 ms |
preloadStaleTime | Freshness window for a prefetched entry; defaults to 30_000 ms |
disabled | Prevents click navigation and preloading; retains an accessible anchor with aria-disabled |
activeProps / inactiveProps | Adds anchor props based on active state |
All regular anchor props except href are supported.
Active State
<Link
to="/dashboard"
activeProps={({ isActive }) => ({
"aria-current": isActive ? "page" : undefined,
className: isActive ? "active" : undefined,
})}
>
Dashboard
</Link>
The active comparison uses the logical pathname and search string, excluding the hash. Active links also receive data-status="active". Children may be a render function if the label itself depends on isActive.
Preloading
The default intent strategy starts preloading after a short hover delay or immediately on focus. viewport uses an intersection observer, and render starts when the link mounts. Prefetch entries use an LRU cache with a generated default capacity of 50.
Prefetching loads the destination route module and route data ahead of navigation. Duplicate in-flight work is shared, stale entries are refreshed according to preloadStaleTime, and server invalidation headers evict affected entries.
Use preload={false} for destinations that are expensive, rarely opened, or inappropriate to request before an explicit click.
Module Preloading
Production builds read Bun's chunk graph so JavaScript downloads in parallel instead of one import level at a time:
- Server-rendered pages (SSR, ISR, SSG, static export):
<HeadContent />emits one<link rel="modulepreload">per chunk the current route hydrates (page, layouts, boundaries and all their transitive static imports), minus chunks the entry script already loads. Opt out withdefineConfig({ preload: { routeChunks: false } }). - SPA navigation: preloading a
<Link>imports the target route, and Bun's built-inmodulePreloadadds a modulepreload link for every chunk that route statically imports.
For browser-only modules such as a WebGL scene, declare them with clientModule() and call preloadClientModule() while rendering:
import { clientModule, preloadClientModule } from "@teyik0/furin/client";
import { useEffect } from "react";
const chimeScene = clientModule(() => import("./chime-scene"));
export function ChimeCanvas() {
preloadClientModule(chimeScene); // SSR: <link rel="modulepreload"> in <head>
useEffect(() => {
let disposed = false;
chimeScene.load().then(({ mountChime }) => {
if (!disposed) mountChime();
});
return () => {
disposed = true;
};
}, []);
return <canvas />;
}
The build strips the import() from the server bundle, so the module never runs or ships server-side. The argument must be an inline () => import("…"). In development hrefs is empty and preloading is a no-op: Bun's dev server serves unhashed modules on demand.
Programmatic Navigation
import { useNavigate } from "@teyik0/furin/link";
function SearchButton() {
const navigate = useNavigate();
return (
<button
onClick={() =>
navigate({
to: "/search",
search: { q: "bun", page: 1 },
hash: "results",
})
}
>
Search
</button>
);
}
useNavigate() accepts typed to, params, and search values, plus hash, replace, and resetScroll. Path parameters use the same encoding as <Link>. Dynamic routes require their path parameters:
const navigate = useNavigate();
await navigate({ to: "/blog/:slug", params: { slug: "hello-furin" } });
Unknown internal destinations are rejected by TypeScript as described in destination checking.
useRouter().navigate() and useRouter().prefetch() accept the same typed route patterns, params and search values:
import { useRouter } from "@teyik0/furin/link";
const router = useRouter();
router.prefetch({
to: "/blog/:slug",
params: { slug: "hello-furin" },
staleTime: 30_000,
});
await router.navigate({
to: "/blog/:slug",
params: { slug: "hello-furin" },
replace: true,
});
Prefetch loads data without navigating; navigation reuses it while fresh. Path encoding and search-default omission use the same rules as <Link>. Href strings such as router.navigate("/blog/hello-furin", { replace: true }) and router.prefetch("/blog/hello-furin", { staleTime: 30_000 }) are checked against the route manifest but don't receive params or search typing. Use structured destinations for that and for route-pattern completion.
Unsafe schemes are rejected. External http(s) destinations use a document navigation.
Search State
useSearch() returns the validated search state for a route (schema output, defaults applied) and an async setter:
import { useSearch } from "@teyik0/furin/search";
function Pagination() {
const [page, setSearch] = useSearch("/search", (search) => search.page);
return (
<button onClick={() => setSearch({ page: page + 1 })}>
Next page
</button>
);
}
Updates merge with the current search object. Pass { replace: true } to avoid a new history entry or { resetScroll: false } to preserve the current position. Setting a field back to its schema default removes it from the URL.
The route argument autocompletes generated patterns (for example, /products/:productId). It must be a known route pattern or a path matching one, and it must identify the currently rendered route. A mismatch throws instead of reading or updating search state under the wrong route type.
Router Controls
import { useRouter } from "@teyik0/furin/link";
function RefreshButton() {
const router = useRouter();
return (
<button disabled={router.isNavigating} onClick={() => router.refresh()}>
Refresh
</button>
);
}
Useful members are:
isNavigating: whether a navigation or refresh is active.refresh(options): evicts and reloads the current page without adding history.prefetch(href, options): manually warms a logical URL.invalidatePrefetch(path, type): evicts one page or a layout subtree.
Use router.refresh() after an application action only when the response did not already carry Furin invalidation metadata. Furin automatically consumes X-Furin-Revalidate on browser fetch() responses and refreshes the current route when it is affected.
Scroll And History
- New navigations use
history.pushState;replaceusesreplaceState. - Back and forward restore saved positions.
- A destination hash wins over top-of-page scrolling.
resetScroll={false}suppresses automatic scrolling.- Redirects preserve the server's canonical destination.
If <Link> renders outside Furin's router provider, it remains functional and falls back to a normal document navigation.