Splitting the Grid: a Server-Rendered First Page, a Client Tail
The pillar grid held the whole page render hostage to one API call. Page one stays server-rendered, the rest moved into a client island.
TL;DR
The pillar profile page had a grid with twelve cards. Raising the limit did not speed up the page. The grid was split into server and client components.
Everything in this piece comes from one commit in a city portal I work on, fictionalized here as one product story [1]. The pillar profile page on that portal had one grid titled "Latest Entities". Twelve cards, all of them arriving from a single server request. Twelve was never a design decision, just a number picked when the grid was first written, and nobody had revisited it. One afternoon I opened the page on a slow connection and the grid only appeared after the entire API response finished. My first guess was simple: raise the limit.
That guess aimed in the wrong direction. What made the page feel heavy was not the amount of HTML being sent. It was when the HTML was allowed to be sent at all.
Raising the limit only moves the load
The Next.js docs say it without decoration: if a slow data request exists in a Server Component, the whole route is blocked from rendering until all the data is fetched [3]. A fetch there is not cached by default either, and it holds the render until it completes [3]. So raising my limit from 12 to 20 would not speed anything up. I would only move the same work to a more expensive place, because more rows now have to come back before a single byte reaches the browser.
This is the part that gets lost when people discuss Server Components. The argument is not "the server is faster". The argument is "the server holds the whole route". One slow query in one grid makes the hero, the title, and the navigation on the same page wait with it.
Splitting the list, not wrapping it
The final shape was not one client component wrapping the whole grid. The pillar page stays a Server Component that fetches the first page, now with a limit of 8, inside one Promise.all block alongside categories, hero slides, and the video stream. The result is handed to a client island as the initial rows, together with the entity total, the pillar module name, and the category list for card deep links.
The island owns the rest: the page counter, the "Load more" button, and the loading state. When the button is pressed, it calls the same entities endpoint with the next page and the same limit, then appends the results to the rows already on screen. The 'use client' directive in that file marks one entry point to the client, and only files rendered directly from a Server Component need to carry it [2].
Why not move the entire grid to the client? Because the first page still has to be HTML. That is what crawlers read, that is what shows up before JavaScript runs, and that is what sets the First Contentful Paint. The server-client boundary here runs through the list instead of around it: the first eight cards are HTML, the rest is JSON.
The pillar module travels down from the server page as an already-valid enum, not something read from the query string on the client. The endpoint is read-only, page and limit are bounded on the server, and no user input is reflected into the request. That is what keeps the island safe even though it talks straight to a public API.
Dedupe is not decoration
One small detail is easy to delete during review: before appending a new page, the code builds a Set from the ids of the rows already shown, then filters the fetched results with has(). A Set stores unique values, and lookups inside one are on average faster than scanning an array of the same size [4].
The reason is not performance. Offset pagination shifts the moment a new entity lands between two fetches. Page 2, pulled five minutes after page 1, can repeat one card that page 1 already showed. Without dedupe the reader sees a twin card and concludes the data is broken, when the data is fine and only the ordering moved.
The button and the "Showing 8 of 24 entities" counter are hidden entirely once the row count reaches the total, so there is no dead button to keep clicking. When a fetch fails, one alert line with role="alert" appears and the button can be tried again. The three pillar pages dropped to 8 while the video stream kept 12, because that playlist scrolls inside its own box anyway.
What I take away from this change is not the component pattern. The pattern already existed elsewhere in the same repository and I only copied it. What changed is how I read the limit: whenever a list is rendered in full by the server, the first question is not "how many fit", but "how many are allowed to hold this page's render". Eight cards is enough to make the page look full. The rest can follow.
Sources
- [1] Next.js, Server and Client Components, accessed October 26, 2026.
- [2] Next.js, use client, accessed October 26, 2026.
- [3] Next.js, Fetching Data, accessed October 26, 2026.
- [4] MDN Web Docs, Set, accessed October 26, 2026.