Client Side Navigation (Single Page Apps)
Implement client side navigation in your RedwoodSDK project
What is Client Side Navigation?
Client-side navigation is a technique that allows users to move between pages without a full-page reload. Instead of the browser reloading the entire HTML document, the JavaScript runtime intercepts navigation events (like link clicks), fetches the next page's content (usually as JavaScript modules or RSC payload), and updates the current view.
This approach is commonly referred to as a Single Page App (SPA). In RedwoodSDK, you get SPA-like navigation with server-fetched React Server Components (RSC), so it's fast and dynamic, but still uses the server for rendering. RedwoodSDK uses RSC RPC to emulate client-side navigation.
import { initClient, initClientNavigation } from "rwsdk/client";
const { handleResponse, onHydrated } = initClientNavigation();
initClient({ handleResponse, onHydrated });Note: The onHydrated callback is optional but recommended. It's
required for NavigationPending and for prefetching (see the
Prefetching Routes section). It also handles cache eviction
which helps prevent memory bloat. If you're not using those features, you can omit it and only
pass handleResponse to initClient.
Once this is initialized, internal <a href="/some-path"> links will no longer trigger full-page reloads. Instead, the SDK will:
- Intercept the link click,
- Push the new URL to the browser's history,
- Fetch the new page's RSC payload from the server using a GET request to the current URL with a
?__rscquery parameter (making it cache-friendly for browsers and CDNs), - And hydrate it on the client.
RedwoodSDK keeps everything minimal and transparent. No magic routing system. No nested router contexts. You get the benefits of a modern SPA without giving up control.
Transitions and View Animations
Client-side navigation enables you to animate between pages without jank. Pair it with View Transitions in React 19 to create seamless visual transitions.
Caveats
No routing system is included: RedwoodSDK doesn't provide a client-side router. You can layer your own state management or page transitions as needed.
Only internal links are intercepted: RedwoodSDK will only handle links pointing to the same origin. External links (https://example.com) or those with target="\_blank" behave normally.
Middleware still runs: Every navigation hits your server again — so auth checks, headers, and streaming behavior remain intact.
Showing Loading UI During Pending Navigation
Client-side navigation updates the URL before the new RSC tree commits. If a
server-backed subtree would otherwise show stale props during that gap, wrap it
with NavigationPending inside your own Suspense fallback.
"use client";
import { Suspense } from "react";
import { NavigationPending } from "rwsdk/client";
export function PendingResults({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<ResultsSkeleton />}>
<NavigationPending searchParams={["search", "page"]}>{children}</NavigationPending>
</Suspense>
);
}<NavigationPending> suspends for any pending navigation by default. Pass
searchParams, watch, or when when only some URL changes should affect that
subtree. Use one option at a time; if you combine them, when takes precedence
over watch, and watch takes precedence over searchParams. It requires
passing the onHydrated callback from initClientNavigation() into
initClient().
Watch exact URL parts
Use watch when a subtree should wait for explicit parts of the URL. In this
example, the table only shows its fallback when search or page changes; a
pathname-only or hash-only change will not suspend it.
<Suspense fallback={<ResultsSkeleton />}>
<NavigationPending
watch={{
pathname: false,
searchParams: ["search", "page"],
hash: false,
}}
>
<ResultsTable />
</NavigationPending>
</Suspense>When using watch, pathname defaults to true, searchParams defaults to
true, and hash defaults to false.
Use a custom predicate
Use when for app-specific rules. RedwoodSDK passes copies of the current and
pending URLs, and the subtree suspends when your predicate returns true.
<Suspense fallback={<TabSkeleton />}>
<NavigationPending
when={({ currentUrl, pendingUrl }) =>
currentUrl.searchParams.get("tab") !== pendingUrl.searchParams.get("tab")
}
>
<TabPanel />
</NavigationPending>
</Suspense>Configuring Scroll Behaviour
By default RedwoodSDK jumps to the top of the new page the moment the content finishes rendering – just like a traditional full page load.
If you would like a different experience you can adjust it with
initClientNavigation:
import { initClientNavigation } from "rwsdk/client";
initClientNavigation({
scrollBehavior: "smooth",
});Disable automatic scrolling
For infinite-scroll feeds or chat applications you might want to keep the user
exactly where they were, by setting history.scrollRestoration to "manual" before each navigation action you'll disable the automatic scrolling.
history.scrollRestoration = "manual"; Alternatively you can set scrollToTop: false to disable it completely.
initClientNavigation({
scrollToTop: false,
});Advanced: custom navigation callback
Need to run analytics or state updates before the request is sent? Provide your
own onNavigate handler:
initClientNavigation({
scrollBehavior: "auto",
onNavigate: async () => {
await analytics.track("page_view", { path: window.location.pathname });
},
});Best Practices
- Use the default instant jump for content-heavy pages – it feels identical to a classic navigation and is the least surprising.
- Prefer
scrollBehavior: "smooth"for marketing sites where visual polish is important. - Set
scrollToTop: falsefor timelines or lists that the user is expected to scroll through continuously.
That’s it! No additional code or router configuration required – RedwoodSDK watches for DOM updates and performs the scroll automatically.
Programmatic Navigation
While intercepting link clicks covers most navigation needs, you sometimes need to navigate programmatically - after a form submission, login event, or other user action.
The navigate function allows you to trigger navigation from anywhere in your code:
import { navigate } from "rwsdk/client";
function handleFormSubmit(event: FormEvent) {
event.preventDefault();
navigate("/dashboard");
}import { navigate } from "rwsdk/client";
async function handleLogin(credentials: Credentials) {
await loginUser(credentials);
navigate("/account", { history: "replace" });
}import { navigate } from "rwsdk/client";
function handleSpecialAction() {
navigate("/results", {
info: {
scrollBehavior: "smooth",
scrollToTop: true,
},
});
}The navigate function accepts two parameters:
href: The destination pathoptions: An optional configuration object with:history: Either'push'(default) to add a new history entry, or'replace'to replace the current oneinfo.scrollToTop: Whether to scroll to the top after navigation (default:true)info.scrollBehavior: How to scroll -'instant'(default),'smooth', or'auto'
Prefetching Routes
You can improve navigation performance by prefetching routes that users are likely to visit next. RedwoodSDK automatically detects <link rel="x-prefetch"> elements in your pages and fetches those routes in the background.
How it Works
After each client-side navigation, RedwoodSDK scans the document for <link rel="x-prefetch" href="..."> elements that point to same-origin routes. For each x-prefetch link found, it issues a background GET request with the __rsc query parameter and an x-prefetch: true header. Successful responses are stored in the browser's Cache API.
When a user navigates to a prefetched route, the cached response is used instead of making a network request, resulting in instant navigation.
Basic Usage
Add <link rel="x-prefetch"> tags to your pages or layouts to hint at likely next destinations:
import { link } from "@/shared/links";
export function HomePage() {
const aboutHref = link("/about");
const contactHref = link("/contact");
return (
<>
{/* React 19 will hoist these <link> tags into <head> */}
<link rel="x-prefetch" href={aboutHref} />
<link rel="x-prefetch" href={contactHref} />
<h1>Welcome</h1>
<nav>
<a href={aboutHref}>About</a>
<a href={contactHref}>Contact</a>
</nav>
</>
);
}Prefetching from Navigation Links
A common pattern is to prefetch routes that are linked from the current page:
import { link } from "@/shared/links";
export function BlogListPage({ posts }) {
return (
<>
{posts.map((post) => {
const postHref = link("/blog/:slug", { slug: post.slug });
return (
<article key={post.id}>
<link rel="x-prefetch" href={postHref} />
<a href={postHref}>
<h2>{post.title}</h2>
</a>
</article>
);
})}
</>
);
}Cache Management
RedwoodSDK uses a generation-based cache eviction pattern:
- Cache entries are automatically cleaned up after each navigation to ensure fresh content
- Each browser tab maintains its own cache namespace
- The system avoids races with in-flight prefetch requests
- Cache entries are stored using the browser's Cache API, following standard web platform semantics
This ensures that prefetched content stays fresh while providing the performance benefits of cached navigation.
API Reference
initClientNavigation(options?) Experimental
Initializes the client-side navigation. Call this function from your client.tsx entry point.
Returns: An object with two properties that should be passed to initClient:
handleResponse: A function that handles navigation responses and errors (required for error handling)onHydrated: A function that runs after each hydration to manage cache eviction and prefetching (optional but recommended; required if using prefetching)
Parameters:
options(optional): AClientNavigationOptionsobject:scrollToTop(boolean, default:true): Whether to scroll to the top after navigationscrollBehavior('instant' | 'smooth' | 'auto', default:'instant'): How scrolling happensonNavigate(function, optional): Callback executed after history push but before RSC fetch
Example:
import { initClient, initClientNavigation } from "rwsdk/client";
const { handleResponse, onHydrated } = initClientNavigation();
initClient({ handleResponse, onHydrated });Example with options:
import { initClient, initClientNavigation } from "rwsdk/client";
const { handleResponse, onHydrated } = initClientNavigation({
scrollBehavior: "smooth",
scrollToTop: true,
});
initClient({ handleResponse, onHydrated });