Before starting this topic, let me clarify something: I’m not going to explain what pre-fetching or SSR is, or try to teach different ways of implementing them in Next.js.
I just want to share my experience with these features: why we decided to use them, what goals led us to this decision, what downsides they caused for us, and what benefits they gave us in return.
This is the second part of the series I mentioned before. If you haven't read the previous article yet, you can find it here.
Why SSR and Why Next.js?
First of all, as I mentioned in the last article, we didn't choose Next.js or decide to work with SSR just because of common phrases like "we want better performance" or "we want higher Lighthouse scores".
Believe me, you can find many well-engineered CSR applications that outperform SSR applications in these areas.
Our main goal was to load our pages as fast as possible. (ideally without depending on skeleton screens or waiting for client-side rendering).
Also, caching was not only about improving the user experience. It helped us reduce the number of requests hitting our backend services.
By reducing repeated API calls, we could lower the requests per second going to the server, which is also an important part of keeping the system stable under higher traffic.
We wanted Google to be able to crawl our pages easily, and we had some important pages that didn't require much user interaction (client-side JavaScript events).
For example, our home page and the main pages where our products and services were introduced.
These pages acted more like entry points. They allowed users to start their journey and move into other parts of the application.
So we wanted them to be fast, and the characteristics of our product allowed us to achieve that.
That was one of the reasons we chose Next.js. It provided us with many tools and built-in features that helped us solve these problems.
How we did it
So we started developing pages with static-generated params:
and SSR components, making API calls on the server:
and then passing the data to a renderer component like this:
Everything is rendered on the server. The SectionRenderer component itself is also a server-rendered component. This way, we could separate services properly.
This component is responsible for rendering different components based on each section type, and each type can render either CSR or SSR components.
One good thing about SSR and pre-fetching in Next.js is that you can achieve the same goal in multiple ways. You just need to choose the right approach based on your product requirements.
For example, the homepage we talked about could also include a section that displays private, user-based data that needs to be fetched on the client side only.
We can easily define a placeholder model for this section, push it in the sections list (in what ever index), and render a skeleton inside the fully server-rendered page until the data is ready.
We did exactly this on the homepage to a component which was responsible for showing user wallet data:
We could also call and fetch multiple APIs on the server. In our case, we needed to call two APIs in some pages: one to get all the sections we needed to render, and another to fetch the data for those sections.
and after getting all tabs, we could simply decide to render component, only if their tab is sent at the response:
So as I said, there are many different ways to render components and fetch data in Next.js. We just need to decide which approach fits our app best.
Downsides of using pre-fetching and caching
Using these methods definitely has their advantages. First of all, if we use them correctly, the app becomes faster, components load instantly, and everyone is happy.
But wait! not everyone, I assure you :)
When we deployed the first demo pages, the QA team was the first group to “yell” at us :)
They couldn’t easily access the API calls in the Network tab of DevTools, since most of the data was fetched on the server.
It was also harder to understand which API response was responsible for a specific issue.
Even identifying which team was responsible for a bug became more complicated because the API calls were happening on the server.
So for every reported issue, we first had to figure out whether it was caused by the frontend or it was backend bug.
In my opinion, one of the biggest headaches of server-side data fetching is debugging.
And you know what? We didn’t just give the QA team that headache with server-side APIs, we made it even worse by adding a cache layer and NextJs cache component feature to cache the already pre-fetched responses at build time. :)
With caching components and their related data at build time, you can make the application even faster, and maybe even skeleton-free :)
But when you do this, debugging also becomes harder, especially when the backend has its own caching layer (which is usually the case). So if some data changes and you're still seeing old data, you can’t easily say “it’s a backend bug” anymore :)
With this cache component implementation, we didn’t just make QA unhappy, we also dragged the backend team into the pain :)
But why did we put ourselves through all this trouble, and was it really worth it?
In our case, I should say, totally! :)
We made this decision based on the product situation — it wasn’t something we implemented for fun.
In the app, there were a considerable number of components whose data was not expected to change frequently.
Caching this type of data results in faster load times and fewer API calls.
But we didn’t want QA or other teams to suffer from this amazing feature. So we came up with a workaround that allowed other teams to revalidate the cache at runtime.
We built a small utility that rendered an input inside the app, allowing QA to manually revalidate cached data. They could enter the cache tag name (which we provided in a list), and instantly see the updated content.
So in my experience: don’t go for caching components unless you’re sure your app actually needs it — and your product manager explicitly asks for it :)
A Few Notes Before Diving Into Server side API call and Cache Components
Based on my experience with this feature, using use cache can sometimes make things worse, or even hurt both the user and developer experience in certain situations.
So it’s better to use it with caution, and remember: don’t force it into your app. Your app can still feel smooth and fast without it.
Some things I learned:
- Use it only when your app can actually benefit from it.
Use it for sections or pages that contain static data, or data that does not change frequently.
Don’t decide to add caching just because you can. This kind of decision is strongly tied to product requirements and should be made with the product team.
Also, if you decide to use caching, it’s better to have a clear cache-time policy that every page or section follows.
In simpler words: avoid hardcoding cache times everywhere. Instead, define a global cache policy and pass the proper value to your cached components.
This way, you know exactly how many cached components you have and how long each section takes to re-validate.
- Don’t use cache components for sections that depend on user interaction or are triggered by user events.
Calling APIs on Server + Cache Components | Usage and Workarounds
As I mentioned before, I recommend avoiding cache components for parts of the app that have user interactions. If you cache these parts, you have to deal with some annoying side effects.
But why? And what if you really need caching for those parts?
Let me tell you a story about a situation we faced.
On our vendor page, we had two components related to Comments and Questions. The data inside these sections looked like a good candidate for
use cache. They were mostly lists of user comments and questions, and we could re-validate their data from time to time.
But these sections were not just for reading. Users could like comments, write comments, and ask questions.
So when we simply cached these sections, things started to get complicated.
For example, after liking a comment, the section would update at first glance. But after revisiting the page or refreshing it, everything went back to the cached state that had been generated at build time.
This issue was not only related to the like button. New comments, questions, and answers also wouldn’t appear as quickly as users expected.
Another important issue was related to the nature of these sections.
You might say: "Why not keep the cache and just re-validate it after every user interaction?"
We actually tried this. But was it really solving the problem?
Reading comments or questions was not a private, user-specific action. But commenting, asking questions, and liking something were strongly connected to a specific user.
Imagine a user clicked the Like button. We updated the state and changed the button style to show that this user had liked the comment.
But after refreshing the page or visiting it again, even if we had the latest revalidated data and the updated like count, we still couldn't know which exact comment had been liked by this specific user, at least until the user identity was available.
Another problem was that these Comment and Question sections were shared across different pages of the app. Users could interact with them from multiple places, which meant every page needed to display the correct state after user actions.
You might say: "Then just wait until the user data arrives on the client and render these sections."
But my question is: was it really worth adding this much complexity and handling all these side effects just for two components?
Our decision was: hell no. :)
Also, after checking the real impact, we noticed that server-side fetching and caching these sections did not have a significant SEO benefit.
Since these sections were mostly user-driven and interactive, we decided that forcing them into server rendering was not worth the extra complexity.
So we changed the approach completely. We moved these sections to be fetched and rendered on the client side.
Simple, no unnecessary side effects, and no frontend cache problems.
So when it came to user interaction and user-driven features, in my experience, the best approach was usually not to think about server-side API calls and cache components.
But what if you actually need server-side API calls or cache components?
That’s another story.
Again, on our vendor page, we had a section responsible for displaying coupons. Each coupon item was interactive, and users could add them to their cart.
For some reason (let’s just say some SEO stuff 😄), we needed to fetch this data on the server.
Calling APIs on the server and caching the result at build time could be problematic in this case for the exact reasons we talked about before.
But this time, we did both.
We fetched the coupon list on the server, cached it, and then passed the data to the client to handle the user interactions.
But we knew how important it was to keep this list updated and display the correct coupons.
Our vendors could change anything about their coupons: update the remaining amount, close a coupon for some reason, or modify other details. Caching this kind of data without properly handling updates could become a disaster.
So what are the workarounds for handling these situations?
One simple solution is to call the already server-fetched API again on the client side.
But where exactly should we make this client-side API call?
There are different ways to do it, but obviously, you shouldn't place it at the exact same level as the server-side call.
My opinion: do it at the level where the update actually affects the user experience.
For example, in our coupon list, we rendered an "Add" button for each coupon that was available to be added to the cart. So we triggered the client-side API call at that level.
Our client-side API calls were handled with React Query, which also provides caching for API responses. This means that no matter how many coupon items used this hook, the API request would only be made once.
So we called the related hook there and compared the client-fetched data with the data we received from the server. If anything had changed, we could update the UI accordingly.
And then, if isInFreshList was false, we could either unmount the Add button completely or simply hide it to avoid unnecessary layout recalculations

Here is another workaround, this one is related to loading and using Suspense when working with server-side API calls and pre-fetching.
This workaround was actually from one of our talented colleague.
When working with server-side APIs, we should always consider wrapping components that need to wait for data with Suspense.
This way, we don’t pause the rendering of the whole page just because one or two components.
Something like this:
The thing is, the fallback doesn’t always need to be a placeholder or a skeleton. We could actually use the pre-fetched data we already had from build time and pass that component as the fallback of the Suspense boundary.
Then the main component, which is responsible for fetching the fresh data, could load in the background and replace the fallback once the latest data was ready.
You can use this method, especially when you have to deal with query params on your page.
You can’t pre-fetch every possible combination of params, but you can pre-fetch the main API and use its result as a placeholder until the client-side API call is completed.
Of course, users might see previous data for a second, and from your perspective, it might look like a bug.
But in my opinion, using this workaround for sections that are not critical can actually be a very clever hack!
So in this article, I shared my experiences working with SSR and cache components.
In the next article, I will talk about our client-side caching system, how we used React Query, how we handled serving pages from CDN, and the challenges we faced along the way.














Top comments (0)