I was creating a PoC with a configuration like Next.js → GraphQL, and at that time I tried Server Actions, a new feature of Next.js. Since I gave a talk about this, I will write a blog to reflect on it.
About Server Components
First, let’s review Server Components. The addition of Server Components creates two major differences from traditional processing.
- Processing begins on the server
- Handling of components when generating a React tree
Suppose I want to render a component like the one below. Rendering begins when a client who wants to render this requests processing to the server. First, I would like to add that although the communication between the client and server is actually done using JSON, I have not expressed it in JSX or written an overview of the detailed processing. It provides a concise explanation of what is necessary from the user’s perspective.
<div>
<ServerComponent />
<ClientComponent />
</div>
The server that receives the request will generate a React Tree.
At this time, if there are Server Components, pass props and perform processing such as expanding the target component into an HTML element. If you go with the code below, ServerComponent will be expanded to div>p, and ClientComponent will remain as is.
<div>
<div>
<p>i'm a server one</p>
</div>
<ClientComponent />
</div>
Since the processing is done on the server side, there are restrictions on the use of lifecycle hooks (useEffect…), state hooks (useState,,,), and custom hooks. Similarly, since it is not executed on the browser, it is not possible to access the window object, and there are restrictions on the browser’s API.
After converting the Server Components, we will return the generated React Tree to the client in JSON format. It receives it on the client side, generates a React Tree on the client side including the expansion of Client Components, and finally commits the changes to the DOM.
<div>
<div>
<p>i'm a server one</p>
</div>
<div/>
<p>i’m a client one</p>
<div>
</div>
In other words, the difference between Server Components from the user’s perspective is that intermediate processing is involved, and this includes advantages and limitations. The main benefit is that the processing is completed on the server side, so there is no need to bundle the libraries required to render the component on the client side.
A summary of what has been said so far is as follows.
- Processing begins with a request to the server
- Components are handled differently when generating a React tree
- Generate React Tree in two stages
- Restrictions on hooks and browser API calls
- Processing is completed on the server, so there is no need to bundle component dependent libraries
About Server Actions
This feature allows processing of user interactions to be executed on the server side without intervening an intermediate API. It has one purpose similar to Server Components, which is to be able to reduce the size of the JavaScript sent to the client, as described in the documentation below.
Server Actions are an alpha feature in Next.js, built on top of React Actions. They enable server-side data mutations, reduced client-side JavaScript, and progressively enhanced forms. They can be defined inside Server Components and/or called from Client Components: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions
Form Actions
There are two ways to execute Server Actions. First, there is a way to request processing to the server side using the form element’s action attribute or the input element’s form-action attribute.
There is a function that declares the use server directive. This is a declaration to execute this function on the server side, and by passing it to the action attribute, it will be fired when submit is executed. The contents of the input defined in the form are passed to the Server Action function as FormData type, so you can even obtain the input value by processing it.
export default async () => {
const action = async (data: FormData) => {
"use server";
// do something
};
return (
<form action={action}>
<input name="email" />
<button type="submit">submit</button>
</form>
);
};
On the other hand, Unhandled Runtime Error occurs when the Server Action returns an error. To handle this, define error.tsx on the same layout, Error Boundary will work, and an error screen can be displayed.
Custom invocation
There is a way to fire a Server Action without using action and form-action, and that is Custom invocation. Custom invocation execution can be used with Client Components, so it can be expressed flexibly by combining it with various hooks.
To use this feature, first define a Server Action. At this time, declare the use server directive. Also, unlike form action, you can pass any value to the argument.
/** action.ts*/
"use server";
export const action = async (id: string) => {
// do something
};
After defining the Server Action, call it. I think it’s very easy to get a feel for because it’s an implementation similar to traditional React.
'use client'
import { action } from './action'
export const Favorite = () => (
<button
onClick={async () => {
await action('id')
// do something
}}
>
click
<button>
)
I think the concept is very easy to understand once you understand Server Components and Server Actions.
with useTransition
We will use useTransition to manage the state of the component while making requests with Server Actions. useTransition prevents UI blocking by marking certain operations as low priority operations. Let’s think about implementing a like button. This is very useful in cases where you want to implement a flow such as pressing a button → changes are reflected across the loading state, and the task has a low priority, but you want to reflect the loading state.

The code itself is so simple that there is no need to explain it, but it uses the return value of useTransition to mark a low-priority task and determine whether it is loading (disabled) to switch the design.
import { useTransition } from "react";
import { action } from "./action";
export const Favorite = ({ state }: { state: boolean }) => {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() =>
startTransition(async () => {
await action(state);
})
}
disabled={isPending}
className="... hover:... disabled:...">
<span>I Like This</span>
{isPending ? <Circle /> : <ThumbUp />}
</button>
);
};
with useOptimistic
We will update immediately with optimistic updates. In other words, without waiting for the results of asynchronous communication, the expected results are immediately reflected and the next process is started.
React has an experimental hook called experimental_useOptimistic, so this can be implemented by using it as useOptimistic. As an image, useOptimistic manages the state for optimistic update, so
- Assign the initial value
- Write expected results to optimistic state before asynchronous communication
- The asynchronous communication response is returned and the state is updated by updating props.
export const Favorite = ({ state }: { state: boolean }) => {
const [optimisticFavorite, addOptimisticFavorite] = useOptimistic(
favorite,
(_, newState: boolean) => newState,
);
return (
<button
onClick={async () => {
addOptimisticFavorite(!favorite);
await mutateFavorite(id, favorite);
}}>
{/**...*/}
</button>
);
};
There may also be experimental_useFormStatus.
Cache/Revalidation
I feel like the content so far can be used enough, but let’s dig a little deeper into the case I mentioned earlier.

If you implement the design above, I think the flow will be like this.
- Parent gets article list from list API
- Pass individual data as props to child card components
- Child card component POSTs state to Favorites API using article id
Also, when you actually write the code, the image will look like this.
export const Articles = async () => {
const articles = await fetchArticles();
return (
<div>
{articles.map((item) => (
<Article key={item.id} {...item} />
))}
</div>
);
};
If you press the Like button, the response of the list API held by the parent will be different from the response of the current list API. For example, if on one page there is a section that displays a list of likes clicked by the user and a list of articles as in the previous design, a problem will occur where the list of likes is not up to date. As I will explain later, Next.js’s fetch caches the response by default, so there is a problem where the cache and the current API response are inconsistent.
I wrote about problems that can occur with the cache earlier, but I would like to write about the update process with Server Actions and the cache of Next.js.
Cache
Next.js caches the response of a GET request by default when you use the fetch function. The behavior here can be controlled by the options passed to the fetch function, and the behavior changes depending on whether you pass force-cache or no-store to the cache key. The former caches by default, and the latter calls the API for each request without caching.
There are other options such as next.revalidate, but we will omit them here.
On the other hand, if you do not use the fetch function (such as when using an SDK or Server Actions that directly access the DB), or if you make a request to a GraphQL endpoint that retrieves data using a POST request, the fetch cache cannot be used. In this case, you can use the cache function provided by React to cache the function results.
import { cache } from "react";
export const revalidate = 3600; // revalidate the data at most every hour
export const getItem = cache(async (id: string) => {
const item = await db.item.findUnique({ id });
return item;
});
// https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#example
We now know that fetch requests and function execution results can be cached. Returning to the example of passing props from the first parent, we need to update the cache after the Like button is pressed and the update is complete. For Revalidation, check revalidatePath and revalidateTag as they are provided by Next.js.
Revalidation
Using revalidatePath and revalidateTag is very easy, just call them where processing is required.
import { revalidatePath, revalidateTag } from "next/cache";
async function fetchData() {
"use server";
await fetch(/** */);
revalidatePath("/path/to/[slug]");
revalidateTag("article");
}
revalidatePath updates the cache at the URL path level by passing a path defined in Next.js, such as /articles/[id], as an argument.
revalidateTag updates all caches associated with tags added as options of the fetch function. There is no problem if you imagine a function like the one below.
async function fetchArticles() {
const res = await fetch("https://example.com/articles", {
next: { tags: ["articles"] },
});
// revalidate tags
revalidateTag("articles");
}
However, although revalidateTag is an option for the fetch function, it is not an option for the cache function, so it is an option that can only be used with the fetch function. There is something called unstable_cache in the next/cache package, and it seems like you can pass tags, so the situation may change in the future.
Conclusion
- Aim to reduce bundle size with Server Components and Server Actions
- Interactions can be flexibly supported by various combinations.
- Situation where there are major restrictions around API data cache
- Select the technology keeping in mind that it is unstable in the first place and has limitations.
- I feel like it might be okay to adopt unstable if there are no problems and it seems like it can be designed around fetch.