Hidden costs of Next.js RSCs and server actions
While working at Gumroad in September 2024, I was in charge of migrating helper.ai from Django to Next.js (the CEO wanted to fully migrate all company projects to Next.js). This page is a collection of writing that I produced during that time while creating the migration plan and communicating it to the rest of the team.
Note: This writing was from before Next 15, although most of the writing still holds up I think as of April 13th, 2025. Also, I copy/pasted this writing (with minor edits for proofreading, adding links, etc) from my personal notes; the actual text that I further edited and shared in Github + Slack was slightly different (but I don't have access to that writing anymore now that I no longer work at Gumroad)
Writing from the migration plan outline (September 2024)
Reasons to not rely solely on RSCs + server actions:
- Relying purely on RSCs for data fetching means relying on
revalidatePath
/revalidateTag
(server) and/orrouter.refresh()
(client), which rerenders all RSCs on the page (even if you only care to re-render one of the RSCs), which can get expensive + clunky. - We'll likely still need granular, client-side queries (e.g. we do this currently for fetching conversation messages and fetching matching conversations in the "Answer with workflow" modal), and so in those cases, having a typesafe API is great.
- RSCs aren't well-supported on mobile (and server actions aren't usable on Expo), so instead of writing separate, mobile-only logic to query our API, something like tRPC would let us reuse the same API code for both mobile and web.
- It's early days; best practices aren't as established (APIs can change too). tRPC has been around for 3+ years; server actions were released in alpha in Next.js a bit over a year ago
- Not 100% locked into Next.js. We can easily move tRPC procedures to a long-running Express.js server, AWS Lambda, etc if needed.
- Moreover, tRPC procedures can be invoked as server actions, so we can still benefit from server actions and more easily migrate if needed
Writing from a discussion with a coworker
For going all in on RSCs + server actions:
- You can't seamlessly use Next.js RSCs + server actions on Expo (maybe ur seeing Expo's experimental server components and API routes features?); so you need another way to write mobile queries/mutations.
- Similarly, client-side queries will always exist on web (e.g. the Helper "Answer with workflow" modal).
- It's not practical to go all in on RSCs for use cases like that. In Nextjs, the only way to re-render an RSC is
revalidatePath
/revalidateTag
(server) orrouter.refresh()
(client). All 3 of these will rerender the entire page from the<html>
tag down (which means it'll re-render every RSC on the page; so all the data fetching logic in those components will re-run). You can't selectively re-render a singular RSC; they're tied to the page that you use them on. So if you wanted to e.g. write the "Answer with workflow" client-sidefetch
as an RSC that you re-render (in place of re-invokingfetch
+setState()
), you'd have to pay the cost of re-rendering the entire page for every fetch (so e.g. refetching the mailbox switcher, the conversation list, and the currently active conversation just to refresh the "Answer with workflow" modal data).- One way Next.js recommends fixing this is by caching everything in the Vercel data cache so that even though everything will be refetched, some queries will probably return cached data and be fast, excluding the queries you revalidated with
revalidateTag/Path
. That's not a great solution. - Another alternative I think would be to have something like a
/conversations/answer-with-workflow?id=slug
route; this way, whilelayout.tsx
files like the left navigation will still refetch the mailbox switcher data, it won't refetch the whole inbox list + conversation because ur on/conversations/answer-with-workflow?id=slug
instead of/conversations?id=slug
. That's not a great solution either (and at the very least, won't be practical in all cases).
- One way Next.js recommends fixing this is by caching everything in the Vercel data cache so that even though everything will be refetched, some queries will probably return cached data and be fast, excluding the queries you revalidated with
- It's not practical to go all in on RSCs for use cases like that. In Nextjs, the only way to re-render an RSC is
- So in the base case, you'd be creating Next.js API route handlers to invoke cross-platform. To get typesafety on those, you'd need tooling for typesafe URLs + inputs/outputs of the requests, and ideally it works cross-platform. tRPC offers that.
- Then, we get additional benefits: The bulk of the backend logic isn't locked into Next.js/Vercel; exact same syntax for queries/mutations across platforms; Tanstack Query integration; autogeneration of OpenAPI spec if we want it for Helper tool usage or creating Helper API documentation.
- RSCs are still great for the initial load of things (which is why we use them in Helper), but you're always going to have client-side requests (certainly on mobile), and so I don't think you want to go "all in" on these things.
(paraphrased message from coworker) Yeah, reading more about it, I guess RSCs are kind of useless unless you have content that's going to be (mostly) static. At the very least though we can go all-in on
"use server"
, especially since Expo supports it.
- Exactly; RSCs at request time (ignoring PPR) essentially produce static HTML (an "RSC payload") that has "holes", and then the client fills those holes with the client components. After that process is done, re-renders from state updates in client components will re-render client components like normal React, but not the RSCs. Even if you pass an RSC to a client component as props (which you can do; I pushed some code to Github here showing that), re-renders of the client component won't re-render the RSC children (cuz the RSC just gets evaluated once at request time and then gets treated as static unless you revalidate it with the methods I mentioned above). I don't think it has to be this way (like, nothing in React "requires" it to happen this way I don't think), but this is how Next.js implemented them; that may change, but who knows when (if ever).
For server actions/"use server"
: (btw apparently they were rebranded to "server functions" recently?? I'll refer to them as that from now on ig)
- Just to make sure: You definitely should not use server functions for data fetching in Next.js (we do it in a few places in Helper, but we shouldn't). Reasons:
- Server functions always execute serially, even in a
Promise.all
(Youtube video ref)- (this is arguably also a reason to not do mutations with server functions, but generally you won't be doing multiple non-GET requests client-side, so it's not really an issue for mutations)
- Server functions are explicitly specified in the docs as meant for mutations (and all the other indicators suggest that this won't change anytime soon)
- Server functions always execute serially, even in a
- So given that server functions are only for mutations:
- Using server functions means having two methods of interfacing with your backend: you'd use
fetch
for queries, and server functions for mutations. - Using
fetch
for queries isn't type-safe without tooling, and tRPC is a solid tool to address that. If you're already using tRPC for queries, then you likely should also use it for mutations. This way, all queries/mutations across all platforms are defined + invoked in the same way.
- Using server functions means having two methods of interfacing with your backend: you'd use
- If you do use server functions, you'll almost definitely be using next-safe-action (Iffy uses it I think), as it's the best way I think to do the tRPC equivalent of middleware for authorization and setting variables like the
ctx.mailbox
in mailbox procedures. So in that sense, the two tools are pretty similar. - For the point about Expo supporting
"use server"
:- I don't think it does? Could you link me to where you're seeing this? I'm almost positive server functions aren't supported on Expo rn. The only mention I see of "use server" is on this page explaining that
"use server"
isn't related to RSCs.
- I don't think it does? Could you link me to where you're seeing this? I'm almost positive server functions aren't supported on Expo rn. The only mention I see of "use server" is on this page explaining that