Skip to content
Draft
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
151 changes: 151 additions & 0 deletions examples/remix/basic/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# `@tanstack/remix-router` + `@tanstack/remix-start` example

End-to-end demo of TanStack Router on top of Remix 3 (`@remix-run/ui`).
The whole route tree hydrates as one Remix UI mount; TanStack Router
drives navigation and data; server functions keep heavy server-only
work (markdown rendering, DB clients, anything you don't want shipped to
the browser) out of the client bundle.

## Architecture

```
Browser
├─ src/client.tsx
│ await hydrateStart() ← deserialize router state
│ run({ loadModule }) ← Remix UI runtime (for clientEntry islands)
│ createRoot(document.body)
│ .render(<StartClient router={router}/>) ← full-tree mount
├─ Server function calls ──────┐
│ GET/POST /_serverFn/<id> │
└─ Page navigation / data fetch │
↓ │
Server (vite dev → node prod) │
└─ default-entry server │
createStartHandler(defaultStreamHandler)
renderRouterToStream → @remix-run/ui/server.renderToStream
- <StartServer> provides shell
- splices seroval dehydration before </body>
```

One reconciler everywhere: `@remix-run/ui`. TanStack Router subscribes
to its own reactive stores; on store change it calls `handle.update()`
and Remix UI re-runs the render function.

## The five primitives

| Primitive | When to write it | What it costs the client |
|---|---|---|
| **Route component** `function Foo(handle) { return () => <jsx/> }` | every page, layout, nav | full hydration cost |
| **Loader** `loader: () => fetchData()` | data the route needs | the loader code ships unless wrapped — use server fns |
| **`createServerFn({ method }).inputValidator().handler()`** | DB queries, markdown renders, anything with heavy deps or server-only secrets | nothing — body is stripped, replaced with RPC fetcher |
| **`clientEntry(id, fn)`** | per-instance interactive bits that don't need router context (counter, dropdown, video player) | only the island module + its props ship as a separate hydration root |
| **`<Frame src="…">`** | streaming a server-rendered fragment from a URL — opaque to the parent client tree | the URL string + ~10kB Frame runtime |

Default = universal: components run on both sides, no directives. Wrap
to opt out of the client (`createServerFn`, `<Frame>`); wrap to opt in
to standalone hydration (`clientEntry`).

## Routes

| Path | Demonstrates |
|---|---|
| `/` | Static welcome page with a route guide |
| `/users` | Loader-driven list, `<Link>` to nested detail |
| `/users/$id` | `useLoaderData` + `useParams`; server-fn-rendered HTML mounted via `innerHTML` |
| `/posts` | Same shape with markdown content |
| `/posts/$slug` | Heavy markdown + syntax highlighting (server-only deps stay out of client bundle) |
| `/admin/users/$userId/sessions/$sessionId` | 4-deep nested layout via file path; exercises `<Outlet>`/`<MatchContext>` reactivity at every level |
| `/catalog` | `validateSearch`, `loaderDeps`, `<Link search={updater}>`, form-driven `useNavigate` |
| `/slow` | Async loader (800ms), `pendingComponent` UI |
| `/lab/error` | Loader throws → `errorComponent` |
| `/lab/missing` | Loader calls `notFound()` → `notFoundComponent` |
| `/lab/render-error` | Render-time throw caught by enclosing `<CatchBoundary>` |
| `/guestbook` | `createServerFn({ method: 'POST' })` with `inputValidator`; form submit calls server fn from event handler |
| `/counter` | `clientEntry()`-marked island that hydrates standalone (counter `+`/`reset` buttons) |

## Bundle savings

The Vite plugin (`@tanstack/remix-start/plugin/vite` →
`@tanstack/start-plugin-core` under the hood) extracts `createServerFn`
handler bodies from the client bundle, replacing them with RPC
fetchers that hit `${TSS_SERVER_FN_BASE}/<id>`. Heavy server-only
modules (markdown renderers, ORM clients, image pipelines) reachable
only through those handlers fall out of the client bundle entirely.

In this example: `marked`, `highlight.js`, language grammars, and
`heavyDep` (a deliberately-fat user-bio renderer) are all server-only
— the client bundle (~190 KB minified) contains none of them.

## Hydration model

The whole route tree hydrates. There is no selective per-component
hydration — `<Link>`, `useLoaderData`, `useSearch` all work because the
ambient `<StartClient>` mount runs the route's render function on the
client. If you want a *per-instance* interactive piece that doesn't
depend on the router context (a standalone counter, a video player, a
dropdown that's reused across pages), reach for `clientEntry()`. That
pattern is rare — the route tree already gives you reactivity.

This is fundamentally different from RSC. There are no `"use client"` /
`"use server"` directives. The boundary is the wrapped *export*
(`createServerFn(...)`, `clientEntry(...)`), not a file-level marker. A
consequence: there's no built-in way to author "this region is
server-only HTML with client islands inside" inline in the JSX tree —
that pattern needs `<Frame src="…">` (URL-driven, see below) or a
separate primitive that doesn't yet exist.

## A note on `defer()` / `<Await>`

The binding ships `defer()` and `<Await>` and they SSR correctly with
the fallback inline. Two follow-up fixes are needed before the slow
chunk streams in via seroval:

1. **`awaited.tsx` server-side guard** *(done — landed in this branch)*:
`onSettle → handle.update()` must skip on the server, since the SSR
scheduler doesn't implement `scheduleUpdate` and the post-stream
update was crashing the dev server.
2. **Plumb seroval streaming chunks through `pipeWithDehydration`**:
the resolution chunk for a deferred promise is buffered server-side
(`scriptBuffer.enqueue`) but isn't reaching the response body.
`collectInjection()` runs once at the end of the stream after
serialization completes; the chunk SHOULD be in the buffered
scripts at that point, but empirically the response only contains
the initial dehydration. Needs investigation — likely a missing
subscribe somewhere between the seroval `onSerialize` callback and
the `injectScript` flush, or a timing issue with the script-buffer
barrier lift.

The `/deferred` route has been left out for now to keep the demo
green. Once (2) is fixed, restore it from git history.

## A note on `<Frame>`

`@remix-run/ui` ships a `<Frame src="…">` primitive that streams an
HTML fragment from a URL. It's powerful — multiple frames stream in
parallel out-of-order, each with its own fallback — but it solves a
narrow problem: shipping pre-rendered HTML when the renderer (e.g. a
markdown + syntax-highlighting pipeline) can't be bundled to the
client. For most apps the existing patterns cover this:

- *"Heavy server-only render → mount as HTML"* is what `/posts/$slug`
already does: a `createServerFn` returns the HTML string, the route
loader awaits it, the component mounts via `innerHTML`. No async URL
builder, no `resolveFrame` plumbing.
- *"Stream multiple async values into a route"* is `defer()` /{' '}
`<Await>` (see `/deferred`).

Reach for `<Frame>` only if you specifically need N parallel
HTML-only renders streaming independently — rare in practice. The
binding supports it; this example just doesn't teach it because the
better-fit primitives already cover the 80% case.

## Running

```sh
pnpm install
pnpm dev # vite dev server with full SSR + SPA navigation
pnpm build # production build (client + server bundles)
pnpm preview # preview production build
```
12 changes: 12 additions & 0 deletions examples/remix/basic/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>remix-router demo</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/entry.client.tsx"></script>
</body>
</html>
22 changes: 22 additions & 0 deletions examples/remix/basic/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "tanstack-remix-router-example-basic",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "End-to-end demo of @tanstack/remix-router on top of Remix 3.",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@remix-run/ui": "^0.1.1",
"@tanstack/remix-router": "workspace:*",
"@tanstack/remix-start": "workspace:*",
"highlight.js": "^11.10.0",
"marked": "^15.0.0"
},
"devDependencies": {
"vite": "^8.0.0"
}
}
52 changes: 52 additions & 0 deletions examples/remix/basic/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createServer } from 'node:http'
import { Readable } from 'node:stream'
import { createStartHandler } from '@tanstack/remix-start/server'
import { makeRouter } from './src/router.ts'

/**
* The Start handler dispatches `/_serverFn/<id>` requests to
* `handleServerAction` (the framework-agnostic RPC runtime) and falls
* through to the TSR app handler for everything else. Server functions
* declared via `createServerFn` are exposed automatically — no
* hand-rolled API endpoints to maintain.
*
* `<Frame>` SSR works out of the box: the underlying router handler's
* default `resolveFrame` recurses through this same handler, so a
* `<Frame src="/_serverFn/<id>">` resolves through the server-action
* runtime — exactly the same path a client-side RPC call takes.
*/
const handler = createStartHandler({ createRouter: makeRouter })

const port = Number(process.env.PORT ?? 3000)
createServer(async (req, res) => {
const url = `http://${req.headers.host}${req.url}`
const headers = new Headers()
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
for (const v of value) headers.append(key, v)
} else if (value !== undefined) {
headers.set(key, value)
}
}
const body =
req.method === 'GET' || req.method === 'HEAD'
? undefined
: (Readable.toWeb(req) as ReadableStream<Uint8Array>)
const request = new Request(url, {
method: req.method,
headers,
body,
duplex: body ? 'half' : undefined,
} as RequestInit)

const response = await handler(request)
res.statusCode = response.status
response.headers.forEach((v, k) => res.setHeader(k, v))
if (response.body) {
Readable.fromWeb(response.body as any).pipe(res)
} else {
res.end()
}
}).listen(port, () => {
console.log(`listening on http://localhost:${port}`)
})
23 changes: 23 additions & 0 deletions examples/remix/basic/src/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** @jsxRuntime automatic */
/** @jsxImportSource @remix-run/ui */
import { createRoot, run } from '@remix-run/ui'
import { StartClient, hydrateStart } from '@tanstack/remix-start/client'

const router = await hydrateStart()

// Initialize the Remix UI runtime so `clientEntry()` islands hydrate.
// `loadModule(url, exportName)` must return the named export directly,
// not the module namespace — the runtime expects a function.
run({
loadModule: async (url: string, exportName: string) => {
const mod = await import(/* @vite-ignore */ url)
return mod[exportName]
},
})

// Mount the router's render tree against the existing SSR'd body. The
// document shell is server-only (see `<StartServer>`); on the client
// we hydrate against `document.body` since the route render tree is
// scoped to body content.
const root = createRoot(document.body)
root.render(<StartClient router={router} />)
57 changes: 57 additions & 0 deletions examples/remix/basic/src/components/IslandCounter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/** @jsxRuntime automatic */
/** @jsxImportSource @remix-run/ui */
import { clientEntry, on } from '@remix-run/ui'
import type { Handle } from '@remix-run/ui'

/**
* Standalone client-hydrated component (a "client entry"). The first
* argument is the entry id — the SSR pipeline emits a hydration marker
* with this id, and the client runtime mounts JUST this component
* against the marker. The rest of the page can stay non-interactive
* static HTML.
*
* The id syntax `<module-url>#<export-name>` tells the runtime which
* module to dynamically import and which export to instantiate. With
* the Vite plugin in play the URL gets resolved to the deployed chunk
* URL automatically.
*/
export const IslandCounter = clientEntry(
'/src/components/IslandCounter.tsx#IslandCounter',
function IslandCounter(handle: Handle<{ initial?: number; label?: string }>) {
let count = handle.props.initial ?? 0
return ({ initial: _ignored, label = 'Count' }: { initial?: number; label?: string }) => (
<div
style={{
display: 'inline-block',
padding: '0.5rem 0.75rem',
border: '1px solid #888',
borderRadius: 4,
}}
>
<strong>{label}:</strong> <code>{count}</code>{' '}
<button
type="button"
mix={[
on<HTMLButtonElement, 'click'>('click', () => {
count++
void handle.update()
}),
]}
>
+
</button>{' '}
<button
type="button"
mix={[
on<HTMLButtonElement, 'click'>('click', () => {
count = 0
void handle.update()
}),
]}
>
reset
</button>
</div>
)
},
)
33 changes: 33 additions & 0 deletions examples/remix/basic/src/components/LabErrorComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/** @jsxRuntime automatic */
/** @jsxImportSource @remix-run/ui */
import { on } from '@remix-run/ui'
import type { Handle } from '@remix-run/ui'

/**
* Error component used by `/lab/*` routes. The render fn receives
* `{ error, reset }` from the framework — `reset()` calls
* `router.invalidate()` which retries loaders and clears the captured
* error state.
*/
export function LabErrorComponent(
_handle: Handle<{ error: unknown; reset: () => void }>,
) {
return ({
error,
reset,
}: {
error: unknown
reset: () => void
}) => (
<article style={{ border: '1px solid #c33', padding: '0.75rem' }}>
<h2 style={{ color: '#c33', margin: 0 }}>Caught error</h2>
<pre>{error instanceof Error ? error.message : String(error)}</pre>
<button
type="button"
mix={[on<HTMLButtonElement, 'click'>('click', () => reset())]}
>
Retry
</button>
</article>
)
}
Loading
Loading