Server actions were the last missing part of the server-first approach encouraged by Next.js since introducing the app directory in version 13. First in alpha, then to be marked as a stable, “production ready” feature in the most recent update to version 14. It goes without saying that the introduction of server actions had a rocky start to say the least. Social media like X or Reddit flooded with an unfortunate photo of a simplified server action use case and rapidly became a meme in the IT community.
The unfortunate server action use case:
This, and already big confusion about server and client components, did not help Next's team convince developers to migrate to the new app directory. Nevertheless, it is a new way of developing web applications, so this is worth checking out.
Server actions are a very complex topic that might be hard to get a head around due to many different ways of implementation. Some of these are Inline, inside a component, or in separate files. To top it all off, server action implementation will differ depending on whether it's used in a client component (with a "use client" clause) or a server component.
Note: by client component, it does not mean CSR (Client Side Rendering). The clause "use client" still renders components on the server first, but it also sends javascript code to handle any interactivity, states, events etc. (also known as hydration).
The goal of this blog post is to create a comprehensive, easy to follow and hands-on guide that will bring some order to all of this chaos. First, explaining the basics to then dive deeper tackle this complex feature to give a solid foundation to how server actions work and how to implement them.
The tip of the iceberg
Before more complex examples can be introduced, the basics must be mastered. One of the first questions that might be asked is, What is a server action? Answering this question is best led by example.
export default function LoginPage() {
const login = async (formData: FormData) => {
"use server"; // mark "login" function as server action.
const username = formData.get("username") as string;
const user = await db.query.users.findFirst({
where: eq(users.username, username),
});
if (user) {
redirect(`/${user.id}/dashboard`);
}
};
return (
{/* passing the server action as action prop to form element */}
<form action={login}>
<input name="username" />
<button type="submit">Login</button>
</form>
);
}
The simple answer to the above question is that a server action is just an async method with a ”use server"
clause that is used in the form’s action
prop. This is just scratching the surface, and to better understand server actions, it is required to look under the hood.
Next.js sees a login
server action, creates an endpoint and then makes a POST
request to that endpoint when the form is being submitted. Moreover, Next.js also handles parsing the incoming request and gives the server action access to the FormData
object (from which any data of the form can be extracted).
This makes code cleaner and cuts out the necessity for creating an endpoint and handling form submission by the developer, which removes a lot of boilerplate code. Less boilerplate code is always welcomed, but this is not the best part.
What makes server actions so vital in the server-first approach is that this form does not require any javascript to be shipped to the browser. The above example will work as expected even if the end user has javascript disabled. Rendering the page and all the functionality regarding the form is handled on the server.
What Next.js does not show - handling out of scope data
When using a server action inside a component, it can be inviting to use some variables outside of the scope of the server action. Just like in any other regular methods defined inside a component.
type Todo = {
id: string;
title: string;
isDone: boolean;
};
function TodoItem({ id, title, isDone }: Todo) {
const toggleDone = async () => {
"use server";
// isDone and id come from props and are out of action scope.
await db
.update(todos)
.set({ isDone: !isDone })
.where(eq(todos.id, id));
};
return (
<form action={toggleDone}>
<label>
<input
type="checkbox"
defaultChecked={isDone}
/>
{title}
</label>
</form>
);
}
The server action will still work as expected. Next.js handles this case in a very interesting way. In the hidden layers of abstraction, Vercel’s meta framework changes the shape of the form that is being rendered in the browser by adding all the out of scope variables as hidden inputs.
When inspecting the code in the dev tools, the above example will be rendered like so:
<form action="" enctype="multipart/form-data" method="POST">
<input type="hidden" name="$ACTION_REF_1">
<input type="hidden" name="$ACTION_1:1" value="["$@2"]">
<input type="hidden" name="$ACTION_1:0" value="{"id":"41fc7f0d126725bf7d4a0b524dc1123ec440eefe","bound":"$@1"}">
<input type="hidden" name="$ACTION_1:2" value=""GAvSldXmVWE1Er+cwfGNemVEWxIFCUrzttGnbOjlNcIdJLeD+o1ktXM8cC55PyTarzNnSvMUkm1YfK3NlDB7rSPA60dgLlGsLFp0iVSv6tFk3NDAS84JQaA="">
<label>
<input type="checkbox">Write a blog post
</label>
</form>
Using out of scope variables can be useful because it removes the need to handle a FormData
object, which can get tedious when working with bigger, more complex forms. However, it's important to note that any variable that's out of server action scope will be shipped to and rendered in the browser, so it's advised against passing any private information, such as secrets or environment variables.
Storing server actions in separate files
There can be multiple use cases where one server action logic could be used in different places in the codebase, e.g. deleting or updating an item. Server actions can be stored in separate files, thus making them reusable.
// actions.ts
"use server";
/*
By marking this whole file with "use server"
Next will know that any exported function
inside this file should be considered a server action.
*/
export const toggleDone = async (formData: FormData) => {
const todoId = formData.get("id") as string;
const isDone = (formData.get("isDone") as string) === "true";
await db
.update(todos)
.set({ isDone: !isDone })
.where(eq(todos.id, todoId));
};
Using "use server" at the top level of the file, will also prevent exporting anything else, but functions from that file.
// TodoItem.tsx
import { toggleDone } from "@/lib/actions";
function TodoItem({ id, title, isDone }: Todo) {
return (
<form action={toggleDone}>
<input type="hidden" name="id" defaultValue={id} />
<label>
<input
type="checkbox"
name="isDone"
defaultChecked={isDone}
/>
{title}
</label>
</form>
);
}
Storing server actions in separate files can make a codebase more organized. This pattern at first can make it impossible to use out of scope variables, though. To get the required data it's also required to parse the FormData
object. It can be viewed as counterproductive, especially when server action just needs the ID of an item. Next.js still allows passing custom data to server action with the help of a well known .bind()
method.
Adding custom values to server actions
To pass custom variables to server action, can be achieved by using the .bind()
method. Next.js, similarly to fetch()
, has created its own implementation of .bind()
that does exactly the same thing as using out of scope variables - it tells Next to change the shape of the form rendered in the browser so that the server action knows what data to expect.
Is it good that Next.js has its own abstractions of native methods like fetch
or bind
is a topic of its own, and was explained more by Kent C. Dodds in his recent article: Why i won't use Next.js.
// actions.ts
"use server"
import { revalidatePath } from "next/cache";
// added todoId and isDone as server action arguments instead of FormData
export const toggleDone = async (todoId: string, isDone: boolean) => {
await db
.update(todos)
.set({ isDone: !isDone })
.where(eq(todos.id, todoId));
// built-in helper function to revalidate (refetch data) given page
revalidatePath("/todos")
}
RevalidatePath will tell Next to refetch every data that is used in the todos
directory to keep client and server state in sync when marking some todo as done.
// TodoItem.tsx
import { toggleDone } from "@/lib/actions";
function TodoItem({ id, title, isDone }: Todo) {
// binding id and isDone to the toggleDone server action
const toggleDoneAction = toggleDone.bind(null, id, isDone);
return (
<form action={toggleDoneAction}>
<label>
<input
type="checkbox"
defaultChecked={isDone}
/>
{title}
</label>
</form>
);
}
Even though the .bind()
method was used, the rule of no javascript shipped to the browser still applies!
Alternatively, instead of using .bind()
values can be passed by creating <input type=”hidden” />
, however the .bind()
method also encodes the values.
Server action in client components
Up to this point, server actions were used inside of server components exclusively. Nowadays, in modern web applications, it's important to provide feedback to the user about e.g. an error in the form field or that form is being processed. In the server-first approach in Next.js, handling any type of interactivity can be done only with the help of client components (with the "use client" clause).
React also provides 2 new hooks to help useserver actions in client components and managing the form state changes - useFormState
and useFormStatus
.
// SubmitButton.tsx
"use client"
import { useFormStatus } from "react-dom";
const SubmitButton = () => {
const { pending } = useFormStatus();
return (
<button type=”submit” disabled={pending}>
{pending ? "Submitting..." : "Submit"}
</button>
);
};
// LoginForm.tsx
"use client"
export default function LoginForm() {
return (
<form action={loginUser}>
<input
placeholder="Enter a username"
name="username"
/>
<SubmitButton />
</form>
);
}
In the above example, the SubmitButton
was implemented as an abstraction to the button component to change the button's label and give users feedback that the form is being submitted/processed.
useFormStatus
will get the status of a parent form element, thus the need for SubmitButton
Handling errors with server action state
Before handling the state of the form. Good help in managing the actions and forms states is to create custom, generic utility types. These utility types will enforce a similar return shape leading to a better organization of the codebase.
If you are new to utility types or generics, you can read our Typescript tips and tricks blog post here where we explain these concepts in greater detail.
Keep in mind that this step is optional, but it not only brings a structure to what action can and will return (thus, you always know what properties to expect from any server action), but also gives type safety in the useFormStatus
hook.
This next example will be more "real life" like, so it can take more time to understand it fully.
// FromState.d.ts
/*
utlity types for handling the form states
*/
type ActionStatus = "default" | "formError";
type FormValidationErrorState<T, Y> = T & {
status: Extract<ActionStatus, "formError">;
fieldErrors: Y;
};
type FormDefaultState<T> = T & {
status: Extract<ActionStatus, "default">;
};
type ActionFormState<TForm, TFieldErrors> =
| FormValidationErrorState<TForm, TFieldErrors>
| FormDefaultState<TForm>;
// actions.ts
type LoginUserForm = Pick<User, "username">;
// creating a type for action state
export type LoginUserFormState = ActionFormState<
VerifyUserForm,
{ username?: string[] | undefined }
>;
// every action used in useFormStatus will have prevState argument
export async function loginUser(
_prevState: LoginUserFormState
formData: FormData,
): Promise<LoginUserFormState> {
const username = formData.get("username") as string;
// validation handled by zod package
const validation = validateUser({ username });
if (!validation.success) {
// returning the validation errors to the client
return {
username,
status: "formError",
fieldErrors: validation.error.flatten().fieldErrors,
};
}
const user = await db.query.users.findFirst({
where: eq(users.username, username)
});
if (!user) {
// returning custom error to the client
return {
username,
status: "formError",
fieldErrors: {
username: ["User with given username does not exist!"],
},
};
}
// if everything executed correctly just redirect to correct page
redirect(`/${user.id}/dashboard`);
}
Defining the return type Promise<LoginUserFormState>
makes it easier to enforce the correct return shape in different if blocks.
// LoginForm.tsx
"use client"
import { useFormState } from "react-dom";
export default function LoginForm() {
/*
useFormState requires a server action and initial state.
Form state and action return has to be of the same shape.
(LoginUserFormState in this example).
*/
const [formState, loginUserAction] = useFormState(loginUser, {
username: "",
status: "default",
});
return (
<form action={loginUserAction}>
{/*
when status is of "formError"
formState has access to fieldErrors object
*/}
{formState.status === "formError" && (
<p className="text-center text-red-400">
{formState.fieldErrors.username}
</p>
)}
<input
placeholder="Enter a username"
name="username"
/>
<SubmitButton />
</form>
);
}
Summary
The core fundamentals of web development shift towards server first patterns. The line between frontend and backend becomes more and more blurry with every new feature. Creating APIs becomes obsolete, being replaced by server components and server actions. As with any bigger change, it may be hard to grasp at first, making it feel like an unnecessary complication of something that was working well in the past. However, changes are often a sign of progress, and it seems that server-first is becoming a standard for the future. Definitely, these new patterns will require more time for the ever growing React community to catch up to. This blog post aims to make the transition less painful by trying to explain one of the core concepts of the new Next.js app directory feature and help other developers jump on the wagon of the bright future of the server-first pattern.