Sub-skill of tanstack. Owns the project's preload policy: defaultPreload: "intent" is set in getRouter(), every <Link> in apps/web/app/ warms its target on hover or touchstart, and the Workbox SW precache from tanstack-router-pwa-deep-links is a separate (additive) layer. Preloading is the SPA-runtime optimization that complements the SSG build output.
When to invoke
- Editing
defaultPreloadinapps/web/app/router.tsxor the matchingturbo/generators/templates/app/app/router.tsx. - Authoring or reviewing a
<Link preload="intent" | "viewport" | "render" | false>per-link override. - Diagnosing a slow first-click navigation when the SW is already installed (the route loader, not the chunk fetch, is usually the lag).
- Deciding whether to bump
defaultPreloadStaleTime(default: don't). - Anyone proposing
quicklink,react-quicklink, or a<link rel=prefetch>polyfill — the answer lives here.
Owns
The project-wide defaultPreload value, the per-<Link> override pattern, the iPad/touchstart rationale for intent over viewport, and the relationship to the Workbox SW precache (additive, different layer).
Defers to
tanstack(parent) — version pin and dispatch.tanstack-router-routing— for the<Link>API itself; thepreloadprop value is owned here, but the<Link>surface (typedto,params,search,Registerdeclaration) lives there.tanstack-router-pwa-deep-links— for the SW precache that complements preload (preload warms the next route in the current session; SW precache hands the same chunk back offline / on repeat visits).tanstack-start-spa-prerender— for what the SSG output looks like (prerendered shell + per-route chunks). This skill consumes that output's chunk graph.playwright-conventions— for the load-bearing ASK-FIRST rule before any preload-behavior verification test.
Dean-stack rules
- SSG describes the build; SPA describes the runtime. Dean-stack ships static HTML/JS/CSS to GH Pages (SSG), but every navigation is client-side via TanStack Router (SPA).
defaultPreloadoperates at the SPA-runtime layer: it preloads the route's code-split JS chunk via<link rel=modulepreload>and runs the route's loader early. It does not prefetch HTML — there is no per-route HTML to prefetch beyond the prerendered shell. - Belt + suspenders with the Workbox SW precache. The SW (see
tanstack-router-pwa-deep-links) precaches every JS/CSS bundle on first visit. Preload warms the next chunk during a session before precache completes, runs route loaders ahead of the click event, and on some browsers bypasses the SW fetch handler (direct from disk cache → memory). The two layers serve different cases — keep both. quicklinkwas considered and rejected.quicklink(and the abandonedreact-quicklinkwrapper) prefetches<link rel=prefetch>for visible linkhrefs — the HTML at the URL. In an SPA-runtime app, every URL resolves to the same prerendered shell, which is already in the SW precache; the prefetch is redundant. The right primitive ismodulepreloadof the route chunk + loader execution on intent — exactly whatdefaultPreload: "intent"does. Do not reintroducequicklink.- iPad reason for
intentoverviewport.intentfires ontouchstart(iOS) andmouseenter(desktop) — only the link the user is about to tap, ~100ms before the click.viewportfires when the link enters the viewport; on a 12.9" iPad, that can mean a dozen visible links per scroll, warming chunks the user will never tap and wasting battery and bandwidth.intentis the project default; promote a link toviewportonly when a hero/primary action genuinely warrants it. - Pillar 4 (CLI-gate-first):
defaultPreload's value is typed by TanStack Router as'intent' | 'viewport' | 'render' | false. A typo failstsgo --noEmitfor free. Same applies to per-<Link>preloadprops.
Patterns
Project default — already wired
// apps/web/app/router.tsx — pinned: @tanstack/react-router ^1.x
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
return createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
});
}
This is the project-wide policy. Every <Link> inherits intent unless its own preload prop overrides it. The same option is set in turbo/generators/templates/app/app/router.tsx so a freshly generated app starts with the same policy.
Per-link override
import { Link } from "@tanstack/react-router"; // see `tanstack-router-routing`
// Hero CTA — warm the chunk as soon as it scrolls into view.
<Link to="/games/maze/$level" params={{ level: 1 }} preload="viewport">
Start the maze
</Link>
// Heavy / rarely-clicked link — opt out entirely.
<Link to="/about" preload={false}>About</Link>
// Aggressive — preload on first render. Use sparingly; defeats code-splitting if overused.
<Link to="/games" preload="render">All games</Link>
Override values: 'intent' | 'viewport' | 'render' | false. Default to project-intent; document the reason next to any override.
defaultPreloadStaleTime — leave at default
TanStack Router caches preloaded route data for a short window; a click within that window reuses the warm result. Tune only if a Playwright run shows a stale loader result reaching the user. Default is fine for IDB-backed loaders — state changes propagate via the BroadcastChannel re-hydration path owned by idb, not via stale-timer expiry.
Verifying a preload fired (manual)
DevTools → Network → "JS" filter; hover a <Link> (or touchstart on iPad over LAN) — a <link rel=modulepreload> for the next route's chunk appears before the click event. If it doesn't fire, either: (a) the route's chunk is already in the initial bundle (no preload needed), (b) the link is preload={false}, or (c) the Register declaration is missing from router.tsx and <Link> fell back to plain <a> (see tanstack-router-routing).
Anti-patterns
- Don't set
defaultPreload: "viewport"as the project default. iPad scroll would warm every visible link;intentis the right default. Per-linkviewportfor a single hero CTA is fine. - Don't set
defaultPreload: "render"as the project default. Renders every route's chunk on initial paint — defeats code-splitting and bloats first-paint. - Don't introduce
quicklink/react-quicklink/ a<link rel=prefetch>polyfill. The router primitive is the right tool for this stack; HTML prefetch is redundant with the SW precache. See "Dean-stack rules" above. - Don't preload routes the user can never reach from the current view. Per-link
preload={false}for off-flow admin or debug links keeps the chunk graph honest. - Don't
useMemoaround<Link>to avoid re-preload. The Compiler memoizes (seereact-compiler-rules); manual memo is noise. - Don't skip the
Registerdeclaration inrouter.tsx. Without it,<Link>falls back to<a>and the preload never fires. Seetanstack-router-routing. - Don't write a Playwright test for preload behavior without asking the user first. Surface structural choices (story-level vs app-level, fixture, what to assert —
<link rel=modulepreload>DOM presence vs. timing, reduced-motion) and wait for the answer; ASK FIRST is load-bearing (seeplaywright-conventions).
Triggers on
defaultPreload, modulepreload, intent preload, viewport preload, link preload, tanstack router preload, route preload