Next.js 13.5: Server Actions & Router Cache
What I learned about Server Actions and Router Cache in Next 13.5 and a reminder of the importance of source code access to augment even the best documentation.
Our application is using React Server Components and the App Router and Server Actions features of Next 13.5. Its on the cutting edge. đĄď¸ When stuff doesnât just work, I figure itâs on me to go beyond Reading the Fine Manual to really understanding the processes, inputs, and output of the software Iâm building.
Reading the documentation about content caching in Next is a joy. Like all Next documentation, the writing is clear, concise, and filled with information âGood to Knowâ that guides readers down a best-practice path. However, like most documentation for code, it doesnât answer every question a curious developer may imagine - nor does it need to, nor should it want to - and there may be issues in some use cases that are not yet addressed by the documentation. Access to the source code enables developers to move at their own pace, avoiding the risk of blocking adoption.
This felt like a defeat of the purpose of client-side code calling a light-weight Server Action to quickly add one item to a potentially large list and avoid another request for the whole page.
This post fits more squarely in the âcurious developerâ category, though the questions it addresses were raised in my mind as I used caching in anger. I would have been quite frustrated with Next if I had not been able to look at the source code to understand what I was experiencing. What I learned is interesting, but I need to provide some context.
How this startedâŚ
We have a Items
server component that loads list items from the database and passes them to a ItemList
client component. The ItemList
renders on the server when the page loads so we see the list items HTML immediately and it maintains the collection on the client with useState
to track client-side changes to the collection. The component uses Server Actions to add, update, and delete individual items in the database.
âList Oneâ starts with three items rendered on the server. Clicking a button triggers a Server Action to create a new item in the database and return the new item data to the client, which then adds it to the collection of the ItemList
. Reviewing browser network requests, Charles Proxy request tracing, and Next server logs, we see everything is going according to plan. The collection state change causes the component to re-render on the client. đ
Requesting page âList Twoâ at path /lists/2
presents the server rendered items from the other list.
When switching back to the âList Oneâ page, âItem Aâ is no longer present. đł Why wasnât the new collection state retained and the new item still visible in âList One?â
A hard refresh of /lists/1
brought the new item back into view.
This felt like a defeat of the purpose of client-side code to call a light-weight Server Action to quickly add one item to a potentially large list and avoid another request for the whole page.
Perhaps you already know what happened to the new item, and perhaps you also know the exact change necessary to achieve the goal, but did you know that as of Next 13.5, the fix has a surprising side-effect?
Router Cache
I donât intend to reiterate the excellent Next caching documentation, but Iâll point out an aspect of the Router Cache that is relevant to my experience.
The Router Cache temporarily stores the React Server Component Payload in the browser for the duration of a user session
Revisiting page âList Oneâ displayed the items that were originally provided to the ItemList
, rendered by the server into the HTML document as <ul><li>âŚ
, which turns out to be the payload retained by the Router Cache for future client-side renderings of the components at /lists/1
.
It wasnât immediately clear how a page, React Server Component Payload, the ItemList
client component, and the Router Cache were all related, but the documentation made it clear that a cache needed to be cleared.
Cache Revalidation
Next cache management APIs are elegant and conform to web terminology. revalidatePath
correlates to the HTTP response Cache-Control
header directive must-revalidate
and the first line of an HTTP request message, which contains the request method and resource path (i.e. GET /path
). The additional API revalidateTag
makes it easy to revalidate one or many paths associated with a particular tag.
must-revalidate
is a directive to caches, whether they reside in the userâs browser, the corporate proxy, or any number of other devices between the server and the content consumer, indicating to them that the resource theyâre caching must not be delivered or presented once it becomes stale; those caches must revalidate the content.
The documentation led me to believe the Server Action code needed to be changed from this:
export async function addItem(listId: number) {
return createNewItem({ listId, title: 'Item A' })
}
To this:
export async function addItem(listId: number) {
const item = await createNewItem({ listId, title: 'Item A' })
revalidatePath(`/items/${item.listId}`)
return item
}
It worked. Moving between lists did not lose changes that were made to the items. This was the fix I needed, but it led to changes in how the client interacted with server, and I wanted to understand.
Curiosity Made a Blog Post
Being an executive doesnât afford a lot of time for analysis, but weâre wearing the developer hat. đ¤ Curiosity, questions, and experimentation are allowed!
What values were in the
Cache-Control
header of the original page response that had the SSR content of the list?How could the response for the Server Action - a function that only added an item to the database and returned that item in the response, which I verified after coding and testing the page - have any impact on the original response content that was stored by the Router Cache?
Was the complete collection being re-fetched in another request for the page after the Server Action returned? Thatâs load on the server weâre trying to avoid, and the client is doing more work.
Does this mean we donât really need to leverage
useState
in theItemList
client component, if a new server request is issued to re-render the page?Why did a change to âList Oneâ lead to a new request to the server for âList Twoâ when moving between those pages (and changing âList Twoâ now caused another request for âList Oneâ content)?
To save you and I both some time, Iâll lay out whatâs happening to answer these questions and leave it to you to decide how deep youâd like to go into those questions.
What I Learned
Starting from the beginning, before I added revalidatePath
:
The page is dynamically rendered because it uses the
headers()
function to load the JWT session token. Everyone will receive different content, and the content is highly likely to change between requests. There will be no server-side caching, and proxies should never cache the content. The response headers will always includeCache-Control: no-store, must-revalidate
, whereno-store
directs all caches not to store the response.Next Router Cache always caches the content of React Server Components. In our app, the
List
server component loads the item collection and passes it to theItemList
, which is initially rendered on the server and delivered as HTML when we hard-refresh âList One.â TheItemList
is re-rendered on the client when we call thesetCollection
function provided byuseState
(adding a new item returned from the Server Action), and re-rendered again when we switch between âList Oneâ and âList Two.â This render uses the collection originally provided by theList
server component that was stored in the Router Cache; it looks like we lost our item.The Server Action does its job by adding an item to the database and returns a single object in the response JSON. This is what I expected and longed for, keeping the action lightweight and fast. It also includes
Cache-Control: no-store, must-revalidate
in the response header.The Server Action response includes another curious header,
x-action-revalidated: [[],0,0]
. đ¤
After adding revalidatePath
, the UI continued to show the new item when moving between the âList Oneâ and âList Twoâ pages, and no additional request was made after the Server Action response, which is the desired outcome. I needed to understand!
Hereâs what I observed:
The Server Action started returning the complete collection of items in the JSON response (in fact, the response bodies are
Content-Type: text/x-component
, a serialization format I believe is Apache Arrow Flight).The Server Action response
x-action-revalidated
header changed to the value[[],1,0]
. đŻIn fact, the response contained the item collection value that is generated in the
List
server component and passed to theItemList
client component, serialized! Wow. Doesnât this mean that when the Server Action was changed to add a single line,revalidatePath
, the framework re-rendered theList
component on the server and took the values of properties passed to theItemList
and returned them in the response?Searching the Next codebase for that header I found the
addRevalidationHeader()
server function. Here is where I was surprised to find an undocumented, unexpected side-effect; stay tuned. I also found the Server Action client code that handles the action response and particularly, that header.That led me to the client code
applyFlightData()
wherefillCacheWithNewSubTreeData()
is called, doing the work of updating the Router Cache content for the component with the complete collection returned from Server Action, avoiding a need for another request. đ¤Ż
This feels rather magical, and I want to kiss it. đ
Surprise
There was one catch, the surprise I continue to mention, and the reason Iâm thankful for source code access when my curiosity exceeds my desire to write more code.
After adding revalidatePath
, I noticed that after adding an item to âList One,â then moving to âList Twoâ would cause a new request for the content of âList Two.â Thatâs not ideal because nothing changed in âList Twoâ and wasted work was performed.
The source code reveals that revalidatePath
, when used in a Server Action, completely invalidates the Router Cache, erasing every bit of content so far fetched, requiring the client to re-fetch all Server Component content. đł
In fact, there were two requests for âList Twoâ content, going something like this:
GET /lists/2?_rsc=1ugcv
, with a response headercontent-type text/x-component
, and body0:["development",[["children","list",["list",{"children":["__PAGE__",{}]}],null,null]]]
.GET /lists/2?_rsc=6dg7j
, with a response headercontent-type text/x-component
, and a body containing the complete item collection for âList Twoâ (also in the Flight format).
Moving to âList One,â there is another single request:
GET /lists/1?_rsc=36pmj, with a response header
content-type text/x-component
, and body0:["development",[["children","list",["list",{"children":["__PAGE__",{}]}],null,null]]]
.
I eventually realized that these three requests were due to the fact that three server componentsâ content were erased from the Router Cache:
The
Page
component of âList TwoâThe
List
component of âList TwoâThe
Page
component of âList Oneâ
There was no need to request the List
component content for âList Oneâ because it was returned in the Server Action response with the header x-action-revalidated [[],1,0]
.
Notice that our RootLayout
component, which is rendered on the server, is not requested again. Layouts are not pages. Theyâre specially designed to maintain state across pages. revalidatePath
is all about React Server Component content in the Router Cache.
Summary
I learned a lot about React Server Components, Server Actions, and the Router Cache. The documentation for these things is really good, but as I used them and observed their behavior together on a real project, questions were raised the documentation just didnât answer for me. Only by experimentation and reading the code (okay, I also set some DOM mutation and network request breakpoints) was I able to develop a deep understanding of what the framework was doing for me and figure out why more requests were coming to the server than I expected.
I now understand that there is a current limitation in Next 13.5 that will cause clients to make new requests for everything generated by server components if we use revalidatePath
in a Server Action. Perhaps we should should leverage useRouter
and router.refresh()
in the ItemList
after mutating the list or items in it so we can avoid blasting the whole Router Cache. đ¤¨