A Deep Dive into Server Components, Server Actions, and Client-Side Strategies in Next.js
Data Fetching and Caching in Next.js
As a Next.js developer, you're probably used to dealing with data fetching and caching issues. In this article, we will look at server components, server actions, and client-side strategies to help you improve the performance of your Next.js application.
Prerequisites
Before diving into the world of data fetching and caching in Next.js, make sure you have a solid understanding of the following concepts:
Next.js fundamentals: Familiarize yourself with the basics of Next.js, including pages, routing, and server-side rendering. Check out the official Next.js documentation for a comprehensive guide. (https://nextjs.org/docs)
React Hooks: Understand how React Hooks work, as we'll be using them to manage state and side effects in our examples. The official React documentation has an excellent guide on Hooks. (https://react.dev/)
JavaScript and TypeScript: Ensure you have a good grasp of JavaScript and TypeScript basics, as we'll be using them to write our code examples.
TanStack Query: This article assumes that you have used TanStack Query in the past and have some experience around it but you don’t have to be a pro in it.
The Challenges of Data Fetching
Data retrieval is an essential component of any web application, but it can also be a major performance bottleneck. When a user interacts with your application, you must retrieve data from an API or database to update the UI. However, the process can be slow, resulting in a poor user experience.
In traditional client-side rendering, data fetching is typically handled by the client, which can result in: Slow page loads: The client must retrieve data from an API or database, which can take time, which causes slow page loads. Multiple requests: The client may need to make multiple requests to obtain all of the required information, which increases latency and slows page loads.
Now in order to understand how we can use a frontend framework like Next.js to handle these obstacles we will first touch on the concept of server components and server actions which will play a key role in reducing request response time and also be useful in data caching.
Server Components and Server Actions
Next.js 14 adds two new features that will assist us conquer these challenges: server components and server actions. Server components enable us to run React components on the server, whereas server actions enable us to handle server-side requests and responses.
Server Components
Server components are a new type of component introduced in Next.js 14 that may run on a server. This implies that when a client (for example, a browser) makes a request, the app server, which is controlled by Next.js will execute the component and return the HTML content returned by it to the client. They allow us to utilize React on the server, which facilitates code exchange between the server and the client. By default, all Next.js components are server components.
Here's an example of a simple server component that makes an api when the button is clicked:
import { useState } from 'react';
function ServerComponent() {
const [data, setData] = useState(null);
// Simulate an API request
const fetchData = async () => {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
setData(data);
};
return (
<div>
{data ? (
<div>Data: {data}</div>
) : (
<button onClick={fetchData}>Fetch Data</button>
)}
</div>
);
}
export default ServerComponent;
Server Actions
Server actions are a new feature in Next.js 14 that enables us to handle server-side requests and responses. Functions will be treated as a server action when we use ‘use server’ declaration on the top of the file in which the functions are declared. They allow us to create custom API endpoints that can be used to fetch data.
Here's an example of a simple server action that makes an api call to some endpoint and returns the response it got:
‘use server’;
export const action= async (args: any) => {
// Simulate an API request
const data = await fetch('https://api.example.com/data');
const json = await data.json();
return res.status(200).json(json);
};
Server components enable server-side code interoperation with JSX component-driven format, improving the developer experience and reducing response time and bundle size. This is because meaningful content is sent first when a user requests before react is loaded, saving API calls and allowing react to hydrate the app and stylesheets without relying on data.
But this all works well for the first time assuming that your api or database response was fast, things can become dreadful if on every request data is being fetched again and again.
So as for the next step, we will dive into the world of caching, in conjunction with server components and also with client components to handle the issue we mentioned before.
Caching in Next.js
Caching is a crucial aspect of performance optimization. In Next.js, we can use various caching techniques to improve performance.
Request Memoization
Request memoization is a technique that involves caching the results of a request so that subsequent requests can return the cached result instead of re-fetching the data.
Here's an example of using request memoization in Next.js:
‘use client’;
import { useMemo } from 'react';
function Component() {
const [data, setData] = useState(null);
const fetchData = async () => {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
.setData(data);
};
const memoizedFetchData = useMemo(() => {
return fetchData;
}, []);
return (
<div>
{data ? (
<div>Data: {data}</div>
) : (
<button onClick={memoizedFetchData}>Fetch Data</button>
)}
</div>
);
}
This is a very basic method to memoize api calls on the client side. We will take a better approach to pursue it later in the article when we discuss about client side caching strategy.
Data Cache
Data cache that stores the results of data retrieved across incoming server requests. This is feasible as Next.js extends the basic fetch API, allowing each request on the server to specify its own caching behavior. This means that the data will be stored in the server but this only works with server components and server action. The Data Cache is persistent across incoming requests and deployments unless you revalidate or opt out.
This simple example illustrates how easy it is to use data cache.
import react from 'react';
function ServerComponent() {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
return (
<div>
{data ? (
<div>Data: {data}</div>
) : (
<div>Loading...</div>
)}
</div>
);
}
To revalidate, meaning to delete the data from the cache store, we can pass a config object to fetch as a second argument.
fetch('https://...', { next: { revalidate: 3600 } })
Now the cached data will be removed after 3.6 seconds.
Full Route Cache
A full route cache is a technique that involves caching the entire route instead of just the data on the server side.
Router Cache
The Router Cache feature in Next.js 14 improves navigation efficiency by caching data and components, allowing for quicker page transitions. Next.js caches the content of pages you visit, so when you return to those pages, the content is provided from the cache rather than being retrieved again. This lowers network queries and allows for faster page navigation.
The cache store is maintained on the client side rather than on the server.
Client-Side Strategy
In addition to server-side caching techniques, we can also use client-side strategies to improve performance. This is necessary if we need to make api calls after the react component is mounted. One such popular solution is to use TanStack Query formally known as React Query.
TanStack Query
TanStack Query is a popular library for managing data fetching and caching in React applications. It provides a simple and easy-to-use API for caching data and also manages client-side cache state to asynchronous server state. For more details react TanStack Query docs (https://tanstack.com/query/latest/docs/framework/react/overview) as this library is vast and covers a lot of topics that are out of the scope of this article.
Here's an example of using TanStack Query in Next.js:
"use client";
import react from "react";
import { useQuery } from "@tanstack/react-query";
export const getQueryFunction = async ({
queryKey,
}: {
queryKey: [string, string];
}) => {
const [, id] = queryKey;
const response = await fetch(`/api/some-endpoint/${id}`, {
method: "GET",
});
return (await response.json()) as { data };
};
const SomeClientComponent = ({id}: {id: string}) => {
const { isLoading, isSuccess, error, data } = useQuery({
queryKey: ["some-random-key", id],
queryFn: getQueryFunction,
});
if (isSuccess) console.log("Api response: ", data);
return <div> TanStack Example </div>;
};
In this example, we pass an object to the useQuery() function and specify a query key array which takes a key that is like a tag and can be used later to revalidate the query and also the argument that should be passed to the query function.
Query function is a function that will be executed based upon mounting of the component or will be triggered by useQuery() based on config options.
To avoid unnecessary re-renders we can also provide a ‘select’ option which will choose a subset of the data that your component should subscribe to. This is useful for highly efficient data transformations.
For instance,
"use client";
import react from "react";
import { useQuery } from "@tanstack/react-query";
export const getQueryFunction = async ({
queryKey,
}: {
queryKey: [string, string];
}) => {
const [, id] = queryKey;
const response = await fetch(`/api/some-endpoint/${id}`, {
method: "GET",
});
return (await response.json()) as { data };
};
const SomeClientComponent = ({id}: {id: string}) => {
const { isLoading, isSuccess, error, data } = useQuery({
queryKey: ["some-random-key", id],
queryFn: getQueryFunction,
select: (data) => data.length
});
if (isSuccess) console.log("Api response: ", data);
return <div> TanStack Example </div>;
};
Now the component will only re-render if the length of the data changes. It will not re-render if e.g. the name of an entry in the data changed.
To revalidate data from the cache simply use,
queryClient.invalidateQueries({ queryKey: ["Some key"] });
This will clear the cache which is attached to “Some key” tag and when a request is made fresh data will be fetched and cached again.
Combining client side caching with tanstack query to cache client side api calls to avoid unnecessary api calls and and using fetch() api as mentioned in Data Cache section for caching huge chunks of data fetched in initial call can really save a lot of resources on the server side as well as give a snappy experience to the users.
Also keep in mind to also revalidate data (clear cache) on updates to avoid serving stale data instead of the fresh ones.
Conclusion
In this article, we looked at how Next.js handles data fetching and caching. We looked at server-side caching solutions such as request memoization, data cache, complete route cache, and router cache. We also learned about client-side techniques for TanStack Query.
We can utilize these strategies to increase the speed of our Next.js applications while also providing an enhanced user experience. Remember to cache data carefully and only employ caching techniques that are appropriate for your individual use case.
Key Takeaways
- Server-side caching techniques such as request memoization, data cache, full route cache, and router cache can improve performance.
- Client-side caching techniques such as TanStack Query can also improve performance.
- Use caching techniques that make sense for your specific use case.
- Always cache data wisely and use caching techniques that reduce the load on the client and server.
If you are looking to host your website without any hassle with the minimal amount of work done just by making a simple click check our website (https://www.cloudzilla.ai). Cloudzilla has combined the world’s cloud providers into ONE NETWORK, allowing instant code deployments from your repo and effortless roaming of your app across any cloud, anytime.