So this new framework called Tanstack Start has recently come onto the React scene and challenged some of the existing players like Remix and Next. Interestingly it has brought over the concept of server functions from SolidStart. So you might ask yourself are these server components, server actions, or something entirely different?
Let’s start by clarifying that these are not server components. As of right now server functions are for data and not components (this could change as RSC gets integrated into Tanstack Start). They also aren’t quite server actions because those are meant for mutating data. Server functions have some overlap with actions, but the main difference is they can also be used to get data.
So do you need server components to get the benefits of the server in React? I’d argue not! Next has conflated a lot of these topics under RSC and its App Router when they are separate incrementally adoptable concepts. Things like nested routing, OOO streaming, colocated data fetching, and type-safety across the network are all not unique to RSCs. I plan to write a future article on this.
So what are some differences and tradeoffs between the two? Let’s dive in!
Differences
Client by default architecture, with opt in server
Server functions enable a client by default architecture which is familiar to most React devs already. Components generally run on the client and with SSR they run once on the server on initial load. After the initial load of the app it is completely client-side and only when it needs data from the server does it make an api request.
Now we used to make these api requests by hand using fetch. Server functions make these calls transparent over what is called an RPC.
So how is server components different? They are a server-centric approach that require a new architecture and paradigm. They handle SSR similarly in that they render client components on the server. Where things change is on future navigations. When you go to a new page, instead of requesting raw json from the server you request prerendered serialized jsx for the page. React then knows how to turn this into content on the client.
This is what is meant by a server-centric approach. All components are only run on the server unless you specify 'use client'
. Every click is like a whole new page that gets patched into the current page. One benefit of this is that you make only one trip to the server and get all the data you need at once. No client-server waterfalls, but instead you have server waterfalls. Server waterfalls are better, but they are still waterfalls that you should deal with.
You have to decide for yourself which architecture you like better. Do you want to interweave client/server components explicitly or just reach into the server from an isomorphic app as needed in a familiar way.
Granular invalidation
With server functions you make multiple calls for just the little pieces of data you need. You can also invalidate just one piece and refetch it. Server components on the other hand require you to make requests for all data on the next page you want to go to. If you wonder why Next needs a 'use cache'
directive this is why. With the server-centric approach you lean into server caches (which require infrastructure) compared to client caches in the server function approach.
For example if you have two components called HomePage
and AboutPage
and you want to navigate between them it will make a second getUser
call.
// page.tsx
export async function HomePage() {
const user = await getUser();
return (
<>
<h1>Home</h1>
<p>{user.name}</p>
<Link href="/">Go to about</Link>
</>
);
}
// about/page.tsx
export async function AboutPage() {
const user = await getUser();
return (
<>
<h1>About</h1>
<p>{user.name}</p>
<Link href="/">Go home</Link>
</>
);
}
To solve this problem in Next you would add a 'use cache'
to the getUser
function.
async function getUser() {
"use cache";
// return user
}
A client first approach would simply use something like Tanstack Query to cache the getUser
result when navigating.
Hoisting data fetching
So server functions work best when you hoist your data fetching appropriately. This is done in Tanstack Router with its loaders, or in SolidStart with its preload
function. If you don’t do this it can lead to waterfalls.
It is sometimes said the RSC solves all waterfall problems which isn’t exactly accurate. RSC advocates like to say that you don’t need to hoist data fetching in RSC, but I will try and convince you otherwise.
RSC allows you to await
in async
components, which naturally can lead to blocking its children. Siblings are fetched in parallel which is great, but children are not. In that case you still want to hoist out the fetching and then pass the promises down to where they are used.
function HomePage() {
return (
<>
<h1>Home</h1>
<Suspense fallback="Loading...">
<Header />
</Suspense>
</>
);
}
async function Header() {
const user = await getUser();
return (
<>
<h1>User</h1>
{user.name}
{/* The below component won't even start fetching
until the above user is fetched */}
<OnlineUsers />
</>
);
}
async function OnlineUsers() {
const onlineUsers = await getOnlineUsers();
return <>{JSON.stringify(onlineUsers)}</>;
}
As you can see above the <Header/>
component will actually block the getOnlineUsers
call until it gets the unrelated getUser
call.
To fix this you would hoist the getOnlineUsers
call as follows:
function HomePage() {
return (
<>
<h1>Home</h1>
<Suspense fallback="Loading...">
<Header />
</Suspense>
</>
);
}
async function Header() {
const [user, onlineUsers] = await Promise.all([getUser(), getOnlineUsers()]);
return (
<>
<h1>User</h1>
{user.name}
<OnlineUsers onlineUsers={onlineUsers} />
</>
);
}
function OnlineUsers(props) {
return <>{JSON.stringify(props.onlineUsers)}</>;
}
Now wait! Even this isn’t ideal since if you have another unrelated component below <OnlineUsers/>
it will block. So instead of this you can pass the promise down.
function HomePage() {
const onlineUsersPromise = getOnlineUsers();
const userPromise = getUser();
return (
<>
<h1>Home</h1>
<Suspense fallback="Loading...">
<Header
onlineUsersPromise={onlineUsersPromise}
userPromise={userPromise}
/>
</Suspense>
</>
);
}
async function Header(props) {
return (
<>
<User userPromise={props.userPromise} />
<OnlineUsers onlineUsersPromise={props.onlineUsersPromise} />
</>
);
}
async function User(props) {
const user = await props.userPromise;
return (
<>
<h1>User</h1>
{user.name}
</>
);
}
async function OnlineUsers(props) {
const onlineUsers = await props.onlineUsersPromise;
return <>{JSON.stringify(onlineUsers)}</>;
}
A couple things to note here which make things things clunky.
- We needed a new component so the
Header
would not block - We don’t await the promises in
Header
- We pass the promise down to the child components
- Then we await in the child components
So how does something like Tanstack Start differ?
export const Route = createFileRoute("/")({
component: HomePage,
loader: () => {
// note this runs on client and on server
return {
onlineUsersPromise: getOnlineUsers(), // server function
userPromise: getUser(), // server function
};
},
});
function HomePage() {
const { onlineUsersPromise, userPromise } = Route.useLoaderData();
return (
<>
<h1>Home</h1>
<Suspense fallback="Loading...">
<Header
onlineUsersPromise={onlineUsersPromise}
userPromise={userPromise}
/>
</Suspense>
</>
);
}
function Header(props) {
return (
<>
<User userPromise={props.userPromise} />
<OnlineUsers onlineUsersPromise={props.onlineUsersPromise} />
</>
);
}
function User(props) {
const user = use(props.userPromise);
return (
<>
<h1>User</h1>
{user.name}
</>
);
}
function OnlineUsers(props) {
const onlineUsers = use(props.onlineUsersPromise);
return <>{JSON.stringify(onlineUsers)}</>;
}
- We don’t have async components
- We use the React 19
use
primitive instead ofawait
- We use server functions in our loader
Bundle size vs payload size
So where do server components shine? Well it is if you need a smaller bundle size. It isn’t without its cost because you tend to pay for it with a larger payload size since it is serialized vdom. You can render big chunks of your UI on the server and avoid sending those bits to the client. A good example is a big syntax highlighting library.
This also depends on how interactive your site is. If you go back and forth to the server often you might want a reduced payload size, but in e-commerce applications you might want a smaller bundle size.
Conclusion
So I’d say the last point is the main one that matters. Are you optimizing for bundle size, or payload size? Other than that you can argue about the developer experience. Is the server component version better? I personally like the DX of server functions. It’s not quite as easy as just “await in an async component”, but when I can avoid waterfalls and blocking async I think it’s a win.
What are your thoughts?