Skip to content

Add client side navigation #530

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default defineConfig({
label: "Core",
items: [
{ slug: "core/overview" },
{ label: "Request Handling", slug: "core/routing" },
{ label: "Request Handling & Routing", slug: "core/routing" },
{ slug: "core/react-server-components" },
{ slug: "core/database" },
{ slug: "core/storage" },
Expand Down Expand Up @@ -102,16 +102,19 @@ export default defineConfig({
label: "Guides",
items: [
{
label: 'Email',
collapsed: true,
label: "Email",
collapsed: false,
items: [
{ label: 'Sending Email', slug: 'guides/email/sending-email' },
{ label: 'Email Templates', slug: 'guides/email/email-templates' },
]
{ label: "Sending Email", slug: "guides/email/sending-email" },
{
label: "Email Templates",
slug: "guides/email/email-templates",
},
],
},
{
label: 'Frontend Development',
collapsed: true,
label: "Frontend Development",
collapsed: false,
items: [
{ label: "Tailwind CSS", slug: "guides/frontend/tailwind" },
{ label: "Storybook", slug: "guides/frontend/storybook" },
Expand All @@ -123,7 +126,14 @@ export default defineConfig({
slug: "guides/frontend/public-assets",
},
{ label: "Metadata", slug: "guides/frontend/metadata" },
{ label: "Dynamic OG Images", slug: "guides/frontend/og-images" },
{
label: "Dynamic OG Images",
slug: "guides/frontend/og-images",
},
{
label: "Client Side Navigation (SPA)",
slug: "guides/frontend/client-side-nav",
},
],
},
{ label: "Server Function Streams", slug: "guides/rsc-streams" },
Expand All @@ -136,6 +146,7 @@ export default defineConfig({
{ slug: "reference/create-rwsdk" },
{ slug: "reference/sdk-worker" },
{ slug: "reference/sdk-router" },
{ slug: "reference/sdk-client" },
],
collapsed: true,
},
Expand Down
38 changes: 38 additions & 0 deletions docs/src/content/docs/guides/frontend/client-side-nav.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
title: Client Side Navigation (Single Page Apps)
description: 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.

```tsx title="src/client.tsx" ins="initClientNavigation()"
import { initClient, initClientNavigation } from "rwsdk/client";

initClient();
initClientNavigation();
```

Once this is initialized, internal `<a href="/some-path">` links will no longer trigger full-page reloads. Instead, the SDK will:

1. Intercept the link click,
2. Push the new URL to the browser's history,
3. Fetch the new page's RSC payload from the server,
4. And hydrate it on the client.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!


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.
42 changes: 20 additions & 22 deletions docs/src/content/docs/guides/frontend/tailwind.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ title: Tailwind CSS
description: A step-by-step guide for installing and configuring Tailwind CSS v4 in Redwood SDK projects, including customization options and font integration techniques.
---

import { Aside } from '@astrojs/starlight/components';
import { Steps } from '@astrojs/starlight/components';
import { Aside } from "@astrojs/starlight/components";
import { Steps } from "@astrojs/starlight/components";

import { PackageManagers } from "starlight-package-managers";

Expand All @@ -14,40 +14,41 @@ Since the RedwoodSDK is based on React and Vite, we can work through the ["Using

<Steps>

1. Install Tailwind CSS
<PackageManagers
pkg="tailwindcss @tailwindcss/vite"
/>
1. Install Tailwind CSS

<PackageManagers pkg="tailwindcss @tailwindcss/vite" />

2. Configure the Vite Plugin
{" "}

2. Configure the Vite Plugin
```ts ins="import tailwindcss from '@tailwindcss/vite'" ins="tailwindcss()," {3, 12} ins={7-9} title="vite.config.mts"
import { defineConfig } from "vite";
import tailwindcss from '@tailwindcss/vite'
import tailwindcss from "@tailwindcss/vite";
import { redwood } from "rwsdk/vite";

export default defineConfig({
environments: {
ssr: {},
},
plugins: [
redwood(),
tailwindcss(),
],
plugins: [redwood(), tailwindcss()],
});
```

<Aside type="note" title="Environment Configuration">
<Aside type="note" title="Environment Configuration">

Tailwindcss currently uses [the non-deprecated internal `createResolver()` vite API method.](https://github.com/tailwindlabs/tailwindcss/blob/main/packages/%40tailwindcss-vite/src/index.ts#L22) [The code and its docstring indicate that it relies on an `ssr` being present](https://github.com/vitejs/vite/blob/c0e3dba3108e479ab839205cfb046db327bdaf43/packages/vite/src/node/config.ts#L1498).

This isn't the case for us, since we only have a `worker` environment instead of `ssr`. To prevent builds from getting blocked on this, we stub out the ssr environment here.
</Aside>

3. Create a `src/app/styles.css` file, and import Tailwind CSS
</Aside>

3. Create a `src/app/styles.css` file, and import Tailwind CSS

```css title="src/app/styles.css"
@import "tailwindcss";
```

4. Import your CSS and add a `<link>` to the `styles.css` file.<br />
4. Import your CSS and add a `<link>` to the `styles.css` file.<br />
In the `Document.tsx` file, within the `<head>` section, add:

```tsx title="src/app/Document.tsx" add={1, 5}
Expand All @@ -60,14 +61,11 @@ Since the RedwoodSDK is based on React and Vite, we can work through the ["Using
</head>
```

5. To test that Tailwind is working, you'll need to style something in your app. Use the <a href="https://tailwindcss.com/docs/styling-with-utility-classes" target="_blank">Tailwind CSS docs</a> to understand how to use the utility classes.<br />
5. To test that Tailwind is working, you'll need to style something in your app. Use the <a href="https://tailwindcss.com/docs/styling-with-utility-classes" target="_blank">Tailwind CSS docs</a> to understand how to use the utility classes.<br />
For example, you can just pick a random element in your app and add a blue background color to it by adding `className="bg-blue-500"` to it.`

6. Now, you can run `dev` and the element you styled should look different.
<PackageManagers
type="run"
args="dev"
/>
6. Now, you can run `dev` and the element you styled should look different.
<PackageManagers type="run" args="dev" />

</Steps>

Expand Down
15 changes: 15 additions & 0 deletions docs/src/content/docs/reference/sdk-client.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
title: sdk/client
description: Client Side Functions
next: false
---

The `rwsdk/client` module provides a set of functions for client-side operations.

## `initClient`

The `initClient` function is used to initialize the React Client. This hydrates the RSC flight payload that's add at the bottom of the page. This makes the page interactive.

## `initClientNavigation`

The `initClientNavigation` function is used to initialize the client side navigation. An event handler is assocated to clicking the document. If the clicked element contains a link, href, and the href is a relative path, the event handler will be triggered. This will then fetch the RSC payload for the new page, and hydrate it on the client.
65 changes: 65 additions & 0 deletions sdk/src/runtime/clientNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export function initClientNavigation(
opts: {
onNavigate: () => void;
} = {
onNavigate: async function onNavigate() {
// @ts-expect-error
await globalThis.__rsc_callServer();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incredible how simple this is!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's pretty incredible.

},
},
) {
// Intercept all anchor tag clicks
document.addEventListener(
"click",
async function handleClickEvent(event: MouseEvent) {
// should this only work for left click?
if (event.button !== 0) {
return;
}

if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
return;
}

const target = event.target as HTMLElement;
const link = target.closest("a");

if (!link) {
return;
}

const href = link.getAttribute("href");
if (!href) {
return;
}

// Skip if target="_blank" or similar
if (link.target && link.target !== "_self") {
return;
}

// Skip if download attribute
if (link.hasAttribute("download")) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we document this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intuition says that it's such a little footnote that it doesn't really matter. It's the expected behavior.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, woops, I didn't know its an actual standard attribute: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a#download :D

But I also don't know how I was thinking it could be non-standard 🤦

return;
}

// Prevent default navigation
event.preventDefault();

// push this to the history stack.
window.history.pushState(
{ path: href },
"",
window.location.origin + href,
);

await opts.onNavigate();
},
true,
);

// Handle browser back/forward buttons
window.addEventListener("popstate", async function handlePopState() {
await opts.onNavigate();
});
}
1 change: 1 addition & 0 deletions sdk/src/runtime/entries/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "../client";
export * from "../register/client";
export * from "../lib/streams/consumeEventStream";
export * from "../clientNavigation";