|
| 1 | +--- |
| 2 | +title: What If RSCs Were Actually Components? |
| 3 | +published: 2025-01-15 |
| 4 | +authors: |
| 5 | + - Tanner Linsley |
| 6 | + - Manuel Schiller |
| 7 | +--- |
| 8 | + |
| 9 | +React Server Components are a genuine leap for React. They reduce bundle size, stream UI as it resolves, and move work off the client. |
| 10 | + |
| 11 | +But the current model comes with a tradeoff: **the server owns the component tree**. |
| 12 | +Your client code opts into interactivity with `'use client'`. **Composition flows one direction**—server decides, client receives. The React model you know—props, context, bidirectional composition—gets fragmented across environments. |
| 13 | + |
| 14 | +What if it didn't have to? |
| 15 | + |
| 16 | +What if RSCs were actually components? **Fetchable. Cacheable. Composable in both directions.** Primitives that flow through your router, your cache, your data layer—without special directives or framework lock-in. |
| 17 | + |
| 18 | +That's what we built in **TanStack Start**. |
| 19 | + |
| 20 | +--- |
| 21 | + |
| 22 | +## The One-Way Problem |
| 23 | + |
| 24 | +In the traditional RSC model, the server is the only place that decides what interactive elements to render. |
| 25 | + |
| 26 | +Need a `<Link>` with prefetching? **Server** renders it with `'use client'`. |
| 27 | +Need a dashboard widget? Server renders the boundary, marks it client. **The server is always the decision-maker. The client is always the recipient.** |
| 28 | + |
| 29 | +This works. **But it's also limiting.** |
| 30 | + |
| 31 | +What if the server wants to render some interactive elements directly (like `<Link>`) but also **defer entire regions of interactivity back to the client**? |
| 32 | +In the traditional model, you'd create a new client component, a new file, a new boundary. **Every deferral is a seam.** |
| 33 | + |
| 34 | +--- |
| 35 | + |
| 36 | +## Bidirectional Composition |
| 37 | + |
| 38 | +**TanStack Start flips the constraint.** |
| 39 | + |
| 40 | +**Server components can render interactive elements directly**—`<Link>`, `<Button>`, anything marked `'use client'`. That part works the same. |
| 41 | + |
| 42 | +But they can also **declare slots**—regions where the client decides what to render. Not by creating new components or files, but through plain props: children, render functions, whatever pattern you already use. |
| 43 | + |
| 44 | +```tsx |
| 45 | +// Server |
| 46 | +const getPost = createServerFn().handler(async ({ data }) => { |
| 47 | + const post = await db.posts.get(data.postId) |
| 48 | + |
| 49 | + return createServerComponent( |
| 50 | + (props: { |
| 51 | + children?: React.ReactNode |
| 52 | + renderActions?: (data: { |
| 53 | + postId: string |
| 54 | + authorId: string |
| 55 | + }) => React.ReactNode |
| 56 | + }) => ( |
| 57 | + <article> |
| 58 | + <h1>{post.title}</h1> |
| 59 | + <p>{post.body}</p> |
| 60 | + |
| 61 | + {/* Server renders this directly—it's interactive via 'use client' */} |
| 62 | + <Link to={`/posts/${post.nextPostId}`}>Next Post</Link> |
| 63 | + |
| 64 | + {/* Server defers this to the client */} |
| 65 | + <footer> |
| 66 | + {props.renderActions?.({ postId: post.id, authorId: post.authorId })} |
| 67 | + </footer> |
| 68 | + |
| 69 | + {/* Client decides what goes here */} |
| 70 | + {props.children} |
| 71 | + </article> |
| 72 | + ), |
| 73 | + ) |
| 74 | +}) |
| 75 | +``` |
| 76 | + |
| 77 | +```tsx |
| 78 | +// Client |
| 79 | +function PostPage({ postId }) { |
| 80 | + const { data: Post } = useQuery({ |
| 81 | + queryKey: ['post', postId], |
| 82 | + queryFn: () => getPost({ data: { postId } }), |
| 83 | + }) |
| 84 | + |
| 85 | + if (!Post) return <PostSkeleton /> |
| 86 | + |
| 87 | + return ( |
| 88 | + <Post |
| 89 | + renderActions={({ postId, authorId }) => ( |
| 90 | + // Full client interactivity—hooks, state, context, all of it |
| 91 | + <PostActions postId={postId} authorId={authorId} /> |
| 92 | + )} |
| 93 | + > |
| 94 | + <Comments postId={postId} /> |
| 95 | + </Post> |
| 96 | + ) |
| 97 | +} |
| 98 | +``` |
| 99 | + |
| 100 | +**The server rendered the `<Link>` directly.** It also passed `postId` and `authorId` through the slot so the client could render `<PostActions>` with that data. And it left children open for the client to fill with `<Comments>`. |
| 101 | + |
| 102 | +**Both directions. Same component. No new files. No new boundaries.** |
| 103 | +That's inversion of control. |
| 104 | + |
| 105 | +--- |
| 106 | + |
| 107 | +## RSCs as a Primitive |
| 108 | + |
| 109 | +Here's the shift: in TanStack Start, **server components aren't a paradigm**. They're a _primitive_—a serialization format that flows through your existing architecture. |
| 110 | + |
| 111 | +When `createServerFn` returns a server component, that component is a **stream**. Streams are universal. They work with: |
| 112 | + |
| 113 | +- **TanStack Router** → Load server components in route loaders, stream them during navigation |
| 114 | +- **TanStack Query** → Cache server components, refetch in the background, deduplicate requests |
| 115 | +- **TanStack DB** (coming soon) → Sync server component state, offline support, optimistic updates |
| 116 | + |
| 117 | +```tsx |
| 118 | +// In a route loader |
| 119 | +export const Route = createFileRoute('/posts/$postId')({ |
| 120 | + loader: async ({ params }) => ({ |
| 121 | + Post: await getPost({ data: { postId: params.postId } }), |
| 122 | + }), |
| 123 | + component: PostPage, |
| 124 | +}) |
| 125 | + |
| 126 | +function PostPage() { |
| 127 | + const { Post } = Route.useLoaderData() |
| 128 | + |
| 129 | + return ( |
| 130 | + <Post renderActions={({ postId }) => <PostActions postId={postId} />}> |
| 131 | + <Comments /> |
| 132 | + </Post> |
| 133 | + ) |
| 134 | +} |
| 135 | +``` |
| 136 | + |
| 137 | +```tsx |
| 138 | +// With Query caching |
| 139 | +const { data: Layout } = useQuery({ |
| 140 | + queryKey: ['layout'], |
| 141 | + queryFn: () => getLayout(), |
| 142 | + staleTime: 5 * 60 * 1000, // Cache for 5 minutes |
| 143 | +}) |
| 144 | +``` |
| 145 | + |
| 146 | +**The server component is just data.** Fetch it, cache it, transform it, compose it. **No special APIs. No framework-specific caching layers.** Just streams flowing through tools you already know. |
| 147 | + |
| 148 | +--- |
| 149 | + |
| 150 | +## How It Works |
| 151 | + |
| 152 | +When your server component accesses props, it's accessing a **proxy**. |
| 153 | +Every property access and function call is tracked: |
| 154 | + |
| 155 | +- `props.children` → serialized as a slot placeholder |
| 156 | +- `props.renderActions({ postId, authorId })` → serialized with the arguments attached |
| 157 | + |
| 158 | +**Over the wire, it's a React element stream** with embedded placeholders. On the client: |
| 159 | + |
| 160 | +1. The stream decodes into a React element tree |
| 161 | +2. Placeholders match the props you passed when rendering |
| 162 | +3. Render functions replay with the serialized arguments |
| 163 | + |
| 164 | +``` |
| 165 | +Server Client |
| 166 | +─────── ────── |
| 167 | +props.renderActions({ renderActions prop is called |
| 168 | + postId: "abc", → with { postId: "abc", authorId: "xyz" } |
| 169 | + authorId: "xyz" |
| 170 | +}) Your function runs client-side |
| 171 | + with full hooks/state/context |
| 172 | +``` |
| 173 | + |
| 174 | +**Type safety flows through automatically.** The function signature on the server determines what arguments your client function receives: |
| 175 | + |
| 176 | +```tsx |
| 177 | +const getPost = createServerFn().handler(async ({ data }) => { |
| 178 | + const post = await db.posts.get(data.postId) |
| 179 | + |
| 180 | + return createServerComponent( |
| 181 | + (props: { |
| 182 | + renderActions?: (data: { |
| 183 | + postId: string |
| 184 | + authorId: string |
| 185 | + }) => React.ReactNode |
| 186 | + }) => { |
| 187 | + // TypeScript knows exactly what props.renderActions receives |
| 188 | + props.renderActions?.({ postId: post.id, authorId: post.authorId }) |
| 189 | + // ... |
| 190 | + }, |
| 191 | + ) |
| 192 | +}) |
| 193 | +``` |
| 194 | + |
| 195 | +--- |
| 196 | + |
| 197 | +## The Full Spectrum |
| 198 | + |
| 199 | +With RSCs as primitives, TanStack Start covers every frontend use case: |
| 200 | + |
| 201 | +- **Fully Interactive** |
| 202 | + No server components at all. Client-first, SPA-style. RSCs are an optimization you add when helpful, not a paradigm you build around. |
| 203 | + |
| 204 | +- **Hybrid** |
| 205 | + Server components for static shells, data-heavy regions, or SEO-critical content. Slots for interactivity. Mix freely within the same component. |
| 206 | + |
| 207 | +- **Fully Static** |
| 208 | + Pre-render everything at build time. No hydration, no JavaScript. Ship HTML. |
| 209 | + |
| 210 | +**One framework. One mental model. The entire spectrum.** |
| 211 | +You don't choose "interactive framework" or "static framework" or "RSC framework." |
| 212 | +You choose patterns **per-route, per-component, per-use-case**. The architecture supports all of it. |
| 213 | + |
| 214 | +--- |
| 215 | + |
| 216 | +## The Philosophy |
| 217 | + |
| 218 | +React Server Components are powerful. They unlock patterns that weren't possible before. |
| 219 | + |
| 220 | +But the traditional model makes a choice: **the server owns the tree, and interactivity is an escape hatch.** That works for content-heavy sites. It creates friction for apps that are fundamentally interactive. |
| 221 | + |
| 222 | +**TanStack Start doesn't make that choice for you.** |
| 223 | + |
| 224 | +> RSCs are a serialization format—a way to stream React elements from the server. They're a primitive, not a paradigm. Use them when they help. Compose around them when you need control. The client and server are peers, not a hierarchy. |
| 225 | +
|
| 226 | +The server can render interactive elements directly. It can also defer to the client through slots. **Composition flows both directions.** |
| 227 | +You decide the balance, per-component, based on what makes sense. |
| 228 | + |
| 229 | +Because it's not about client or server. |
| 230 | +**It's about using both—intentionally.** |
| 231 | + |
| 232 | +--- |
| 233 | + |
| 234 | +## Current Status: Experimental |
| 235 | + |
| 236 | +This is the first experimental release of RSCs in TanStack Start. A few things to know: |
| 237 | + |
| 238 | +**React's Flight serializer** — This release uses React's native RSC Flight protocol for serialization. That means TanStack Start's usual Seroval-based serialization isn't available within server components for now. Standard JavaScript primitives, Dates, and React elements serialize fine. Custom Seroval plugins and extended types will come in a future release as we unify the serialization layers. |
| 239 | + |
| 240 | +**API surface** — The `createServerComponent` API and slot patterns shown here are stable in design but may see refinements based on feedback. We're shipping early to learn from real usage. |
| 241 | + |
| 242 | +**Performance** — Streaming works today. We're continuing to optimize the ReplayableStream buffering, frame protocol efficiency, and cache integration. |
| 243 | + |
| 244 | +If you hit rough edges, [open an issue](https://github.com/tanstack/router/issues) or drop into [Discord](https://tlinz.com/discord). This is the time to shape the API. |
| 245 | + |
| 246 | +--- |
| 247 | + |
| 248 | +## FAQ |
| 249 | + |
| 250 | +### How does this compare to Next.js App Router? |
| 251 | + |
| 252 | +Next.js App Router is server-first: your component tree lives on the server by default, and you opt into client interactivity with `'use client'` boundaries. |
| 253 | + |
| 254 | +TanStack Start is **isomorphic-first**: your tree lives wherever makes sense, and RSCs are a primitive you pull in when helpful. The key difference is **bidirectional composition**—server components can defer to the client through slots, not just the other way around. |
| 255 | + |
| 256 | +Both approaches support RSCs. They differ in who owns the tree by default and how composition flows. |
| 257 | + |
| 258 | +### Can I use this with my existing Next.js/Remix app? |
| 259 | + |
| 260 | +Not directly—TanStack Start is its own framework built on TanStack Router. But if you're using TanStack Query or TanStack Router already, the mental model transfers. Server components become another data source that Query can cache and Router can load. |
| 261 | + |
| 262 | +### Do I have to use RSCs? |
| 263 | + |
| 264 | +No. RSCs are entirely opt-in. You can build fully client-side SPAs with TanStack Start, use traditional SSR without server components, or go fully static. RSCs are one tool in the spectrum, not a requirement. |
| 265 | + |
| 266 | +### What about React 19 / `use` / Server Actions? |
| 267 | + |
| 268 | +TanStack Start's RSC implementation builds on React's Flight protocol and works with React 19. Server Actions are a separate primitive—`createServerFn` serves a similar purpose but integrates with TanStack's middleware, validation, and caching model. We're watching the Server Actions API and will align where it makes sense. |
| 269 | + |
| 270 | +### When will Seroval serialization work inside RSCs? |
| 271 | + |
| 272 | +It's on the roadmap. The current release uses React's Flight serializer directly, which handles the core use cases. Unifying with Seroval for custom types, extended serialization, and tighter TanStack DB integration is planned for a future release. |
| 273 | + |
| 274 | +--- |
| 275 | + |
| 276 | +## Get Started |
| 277 | + |
| 278 | +TanStack Start's RSC model is available now in experimental. |
| 279 | + |
| 280 | +- [Documentation](https://tanstack.com/start) |
| 281 | +- [GitHub](https://github.com/tanstack/router) |
| 282 | +- [Discord](https://tlinz.com/discord) |
0 commit comments