D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

Private Tier Data Still in Every Page's RSC Payload? A `'use client'` Menu Received Whole Payload CMS Documents

· · 9 min read
Private Tier Data Still in Every Page's RSC Payload? A `'use client'` Menu Received Whole Payload CMS Documents

The client's instruction about the private membership tier was clear: it must not appear on the public site. The tier is invitation only and is reached through a single unlisted address that carries a token, with no navigation link and no public URL. On 15 September 2026, the sentences that still mentioned it were removed from the page copy. Its name was still in every public page, this time inside the RSC payload, and what carried it there was the navigation menu.

What had already been closed

This was not the first leak found for that tier. On 14 September an audit found that the tiers collection answered anyone through REST, which made the private tier public. The patch's commit message records that through the REST and GraphQL doors, the invitation-only tier, its price, its benefits, and its private introduction could all be read by the public. After the patch, the read rule is tenant scoped and lists only the public tiers plus the one a member holds. The same day, the tier's column was deliberately left out of the public comparison table, because the client's own words keep that tier off the public site.

The next thing cleaned up was the visible copy. Until the cleanup commit at 17:24 on 15 September, the pricing intro on the membership page and the fee answer in the FAQ (on home, pricing, and contact) still carried a short sentence saying the tier was by invitation. That sentence was dropped from the seed and from the three string files, and the rows were updated by hand on the local and staging databases in three languages. On the staging database that meant 3 intro rows and 9 answers, which matches one intro and three FAQ answers, each in three languages. The commit's subject states that the tier is no longer mentioned on the public pages.

The leftover name was in the RSC payload

The cause sat in the header. It handed the client menu its navigation items with their page relationships populated. As a result, the whole document of every page the menu linked to, blocks, tiers, prices, and the private tier's benefits included, rode along in every page's payload. On the home page that payload measured 192 KB. The fix's commit message lists the same contents: the layout, the blocks, and the membership tiers with their prices and benefits were serialized into every page's payload, which put the private tier's data on pages where the client wants no trace of it.

If RSC payloads still sound abstract: in the App Router, a component whose file starts with 'use client' is rendered on the server for the first HTML and then hydrated in the browser. For that hydration to work, the props it receives from a server component are serialized into the RSC payload, and on the initial page load that payload is inlined into the HTML as self.__next_f.push(...) script chunks. The serialization does not care which fields the component actually uses. A menu that only shows labels still ships every object it was given, and since the header sits on every page, those objects ship with every page.

From a menu item to a tier's price

The component receiving those items was the phone menu, a client component whose file opens with the 'use client' directive. The header, running on the server, took the menu items straight from the navigation data without changing them and passed them down as a prop. Before the fix, simplified:

// Simplified: project-specific collection, component, and variable names are
// replaced with neutral ones, and the menu's other props are dropped.
 
// Server: the menu data is read through the Local API
payload.find({ collection: 'menus', where, limit: 1, depth: 2, overrideAccess: true, locale })
 
// Header, before the fix: items taken and passed on as they are
const items = menu?.header ?? []
<MenuDrawer items={items} actions={actions} />
 
// MenuDrawer.client.tsx, first line
'use client'

The navigation is read through the Local API with depth: 2, and the fix's commit message gives the reason: the pages inside the menu items need to be populated so their addresses can be built on the server. That part is fine. The trouble was the next step, where the same items were handed to the client menu as they were.

Two levels of relationships are enough to reach the tiers. The link field on a menu item has a page relationship to the pages collection, and the pricing table block in the pages collection has a tiers relationship to the membership tiers collection:

// Simplified from the collection config; collection names are replaced with neutral ones.
 
// The link field on a menu item
{ name: 'page', type: 'relationship', relationTo: 'site-pages' }
 
// The pricing table block in the pages collection. The original comment above the block:
// "nothing here stores a tier name, a price or a benefit.
//  The block holds presentation, and the records supply the substance."
{ name: 'tiers', type: 'relationship', relationTo: 'plans', hasMany: true }

With depth: 2, Payload fills the first level of relationships, the page document inside a menu item, and then the second level inside that document, the tier records in the pricing block. The comment above the pricing block makes a point of saying the block stores no tier name, price, or benefit. The block holds presentation and the tier records hold the substance. That is a sensible way to keep prices in one place. The consequence is that a page document populated two levels deep carries the tier records themselves, prices and benefits included.

So why did the read rule added the day before not stop it? The comment on that rule already answers the question. The pricing page, the join page, and the private invitation page read tiers through the Local API with access overridden and a tenant clause of their own, so the rule exists for the REST and GraphQL doors, which answer anyone. The navigation is read the same way, with overrideAccess: true. In the Local API, overrideAccess defaults to true anyway, and populated relationships are read with the same setting. Nothing on this path filtered out the private tier.

The comment added with the fix sums up the whole chain in one sentence:

The navigation is read with its pages populated so the address can be built here, but a page document carries its whole layout, and handing that to a client component would serialize every linked page into every page's payload.

Only labels and addresses cross to the client

The fix was committed at 17:39, directly on top of the copy cleanup commit, fifteen minutes after it. Its subject says what changed: the phone menu receives labels and addresses, not the page documents. The header now maps every link to a trimmed item holding a label, a resolved address, and a new tab flag. The menu's children get the same treatment, and so do the header's action buttons.

// From the header after the fix; project-specific function and variable
// names are replaced with neutral ones.
const toMenuItem = (link: SiteLink & { id?: string | null }): Item => ({
  id: link.id,
  label: link.label,
  linkType: 'external',
  url: hrefOf(link),
  newTab: link.newTab,
})
 
const items = (menu?.header ?? []).map((item) => ({
  ...toMenuItem(item),
  children: (item.children ?? []).map(toMenuItem),
}))
const actions = (menu?.headerActions ?? []).map(toMenuItem)

Every link becomes an external link whose url was already computed on the server by hrefOf. For the links, what reaches the browser is an address string, not a page document with all of its relationships. The commit touched a single file, the header component, with 13 lines added and 3 removed. The phone menu component was not touched in that commit, and according to the commit message the rendered menus are unchanged.

The way the navigation is read did not change either. At the fix commit, the Local API call still uses depth: 2 and overrideAccess: true, because the server still needs populated pages to build addresses. The only thing that changed is what is allowed to leave the server.

What was measured, and what was not recorded

The same commit message records two results: the home page's payload no longer mentions the private tier, and the client payload shrinks by roughly a quarter. That is the only verification on record. My notes do not say where the check ran, do not keep the payload size after the fix in KB, and do not include a recheck of the payload on staging after the deploy. The commit did not add a test either.

What is on record is the deploy state: that evening, staging carried every change, this fix included. Two days later, on 17 September, the mega menu cards in the header were trimmed with the same pattern, so what gets passed is only the link, the text, and the image's URL, alt, width, and height.

The header fix also did not make every payload small. Late on the night of 15 September, on a local production build, the About page's 282 KB RSC payload was still listed as one of the remaining levers, next to a 117 KB inline map on the same page and the header links' prefetch at 140 KB of fetches. My notes do not say what makes up those 282 KB, and there is no record yet of it being dealt with.

Access rules do not guard props

This case separates two things that are easy to treat as one. Payload's access rules guard the doors anyone can knock on from outside, REST and GraphQL. Server code reading through the Local API with overrideAccess: true may see anything, and that is fine as long as the result stays on the server. Once that result is passed as a prop to a 'use client' component, it ships to every visitor, and at that point access rules no longer play any part.

So whatever crosses into a client component should be shaped on purpose: map the CMS document to a small object holding only the fields that get rendered, like the trimmed items in this header, instead of passing the document through as it is. And when the goal is to make sure something is gone from the public site, check the raw HTML and its payload, not the text on screen. Cleaning up the visible copy only closes the most visible door.