codelynx.dev
🇫🇷🇬🇧

Back • 11/10/2024

The Guide to Server Actions in NextJS

Written by Melvyn Malherbe on 11/10/2024


Server Actions in NextJS have become one of the indispensable and controversial tools of NextJS. They allow you to create applications quickly without needing to handle API Routes.

I will explain precisely:

  • What is a Server Action?
  • How to use it in your application
  • Best practices for your server actions

What is a Server Action?

Server Actions are methods that can be called by your front-end to perform mutations. They replace the API Routes that were previously used for this purpose.

Before, you had to:

  1. Create an API Route
JS
// /api/users/create
export const POST = async (req: NextRequest) => {
  const body = await req.json();

  const createUser = await prisma.user.create({
    data: {
      name: body.name,
      email: body.email,
    },
  });

  return NextResponse.json(data);
};
  1. Make a client-side fetch
JS
const createUser = async () => {
  const res = await fetch('/api/users/create', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      name: 'Melvyn',
      email: 'melvynx@gmail.com',
    }),
  });

  const data = await res.json();

  return data;
};

With Server Actions, all this logic is hidden with NextJS. Server Actions will allow us to make mutations or requests to our backend, such as:

  • modifying data with Prisma
  • launching external services like sending messages via Discord webhooks
  • making requests to external APIs with secrets

You can see it like this:

The advantage of Server Actions is that we no longer need all this data "fetch" logic, and it is possible to simply create a file that starts with "use server" that exports methods, then call these methods.

JS
// /app/users/create-user.action.ts
"use server"

export const createUserAction = async (name: string, email: string) => {
  const createUser = await prisma.user.create({
    data: {
      name: name,
      email: email,
    },
  });

  return createUser;
};

Then in our client, we can simply call this method:

JS
const createUser = async () => {
  const data = await createUserAction('Melvyn', 'melvynx@gmail.com');

  return data;
};

All the logic is managed by NextJS, which will handle calling this method and returning the result to us.

Be careful, you need to be aware of many things because Server Actions are not secure. I will explain the best practices for Server Actions in the continuation.

How to create Server Actions?

There are 3 methods to do this, as Server Actions can also be created directly in Server Components.

Method 1: In a server-component with action

It is possible directly in a server-component to create Server Actions with action.

action is a prop that the form element takes and allows you to perform actions on the form globally.

TSX
// app/users/create/page.tsx
import { redirect } from "next/navigation";

// this is a Server Component!
export default async function Page() {
  // Define a Server Action

  async function handleSubmit(formData: FormData) {
    "use server"
    const name = formData.get('name') as string;
    const email = formData.get('email') as string;

    // Perform a mutation, for example, create a user
    const createUser = await prisma.user.create({
      data: {
        name,
        email,
      },
    });

    redirect("/users")
  }

  return (
    <form action={handleSubmit}>
      <label htmlFor="name">Name</label>
      <input type="text" id="name" name="name" required />

      <label htmlFor="email">Email</label>
      <input type="email" id="email" name="email" required />

      <button type="submit">Create a user</button>
    </form>
  );
}

When we do this, we manage the creation of a user without client-side JavaScript code. You can see that to define the handleSubmit method as a Server Action, there are 2 important rules:

  1. It must be async
  2. It must start with "use server"

When we do these two things, we automatically "expose" this method so that it can be called.

This method takes a FormData that will contain the form's information.

Method 2: In a server-component with formAction

formAction is another method that is only available on buttons and allows you to perform server-actions simply when clicked.

TSX
// app/users/page.tsx
'use server'

import { revalidatePath } from 'next/cache';

export default function Page() {
  const users = await prisma.user.findMany();

  async function handleButtonClick(id) {
    "use server"
    
    await prisma.user.delete({
      where: {
        id,
      }
    })

    revalidatePath("/users");
  }

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Email</th>
          <th>Action</th>
        </tr>
      </thead>
      <tbody>
        {users.map((user) => (
          <tr key={user.id}>
            <td>{user.name}</td>
            <td>{user.email}</td>
            <td>
              <form>
                <button formAction={async () => {
                  "use server"
                  await handleButtonClick(user.id);
                }}>
                  Create User
                </button>
              </form>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Here you can see in this slightly more complex example that I am managing the deletion of a user with just a button. To make it work:

  • the button must be in a form
  • the formAction must be called with async
  • all called methods must be marked "use server"

This example is a good example of how to use Server Actions directly in our Server Components to perform mutations.

Again, this code is not secure. Server Actions are "exposed" to our front-end, and in the above example, a malicious user could delete all users.

Finally, it should be noted that with these two methods, it is not possible to perform client-side code. That is, we must use redirections or the URL to display errors, but it is not possible to call a method like alert or others in our Server Actions.

Method 3: In a client-component

It is possible to mark any method of your application that is in a file as a Server Action with use server.

TSX
// app/users/create/user-create.action.ts
'use server'

export const createUserAction = async (name: string, email: string) => {
  const createUser = await prisma.user.create({
    data: {
      name: name,
      email: email,
    },
  });

  return createUser;
};

This method is now available as a Server Action and can be called by any client.

We could then create a form on the client-side and call this method:

TSX
// app/users/create/page.tsx
'use client'

import { createUserAction } from './user-create.action';
import { useRouter } from 'next/navigation';

export default function Page() {
  const router = useRouter();
  
  const handleSubmit = async (formData: FormData) => {
    const name = formData.get('name') as string;
    const email = formData.get('email') as string;

    const createUser = await createUserAction(name, email);

    alert("The user has been created");
    router.push("/users")
  };

  return (
    <form action={handleSubmit}>
      <label htmlFor="name">Name</label>
      <input type="text" id="name" name="name" required />

      <label htmlFor="email">Email</label>
      <input type="email" id="email" name="email" required />

      <button type="submit">Create a user</button>
    </form>
  );
}

You can see that now we have a handleSubmit method which is also called via the action prop, but be careful, this time we are client-side. That is, everything happens on the client-side and not the server-side.

That's why the handleSubmit method does not have "use server" and will simply call createUserAction. This method will be called on the backend, and the result will be returned.

Then we display an alert (which is available because we are client-side) and redirect the user to the /users page.

Error management

You should know that you cannot throw an error in Server Actions and then retrieve the information like this:

TSX
// action.tsx
"use server";

const createUserAction = async (name: string, email: string) => {
  // ...

  if (!createUser) {
    throw new Error("Error creating user");
  }

  return createUser;
};

// App.jsx
const handleSubmit = async (formData: FormData) => {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  try {
    const createUser = await createUserAction(name, email);
  } catch (error) {
    alert(error.message);
    return
  }

  alert("The user has been created");
  router.push("/users")
};

This code will not work! Because NextJS hides errors in production and assumes that the throw new Error we did above is an undefined error.

You cannot retrieve this error, instead, you should return an error object:

TSX
// action.tsx
"use server";

const createUserAction = async (name: string, email: string) => {
  // ...

  if (!createUser) {
    return {
      error: "Error creating user",
      status: "error",
    }
  }

  return {
    data: createUser,
    status: "success",
  }
};

// App.jsx
const handleSubmit = async (formData: FormData) => {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  const result = await createUserAction(name, email);

  if (result.status === "error") {
    alert(result.error);
    return
  }

  alert("The user has been created");
  router.push("/users")
};

We will see that with my library for managing server-action, there is a simpler way to do it.

How to properly use Server Actions?

You should know that from the moment you use a Server Action in a page, it gets included in the NextJS routing and anyone can call it.

Moreover, any parameters can be given to your Server Action, TypeScript does not protect you from anything.

So, we will see how to create "clean code" for Server Actions.

Finally, we will see how we can use a library to simplify everything.

1. Check user rights.

There is no magic or secret; your code is vulnerable. To solve this problem, you should always check if the user has the right to perform this action. For example:

TSX
// app/users/create/user-create.action.ts
'use server'

import { getCurrentUser } from '~/lib/auth';

export const createUserAction = async (name: string, email: string) => {
  const currentUser = await getCurrentUser();

  if (!currentUser.isAdmin) {
    return {
      error: "You are not allowed to create a user",
    }
  }
  
  const createUser = await prisma.user.create({
    data: {
      name: name,
      email: email,
    },
  });

  return createUser;
};

Another example would be in the case of a modification; you should always check if the user has the right to perform this action.

TSX
// app/users/update/user-update.action.ts
'use server'

import { getCurrentUser } from '~/lib/auth';

export const updateUserAction = async (id: string, name: string, email: string) => {
  const currentUser = await getCurrentUser();

  if (currentUser.id !== id) {  
    return {
      error: "You are not allowed to update this user",
    }
  }

  await prisma.user.update({
    where: {
      id,
    },
    data: {
      name: name,
      email: email,
    },
  });

  return {
    success: "User updated",
  };
};

Here you can see that we are checking the identifiers.

2. Check if the props

The second thing is to check if the props are correct. You can never be sure that the props have been correctly passed to your method.

To verify this, we will use zod:

TSX
// app/users/create/user-create.action.ts
'use server'

import { z } from 'zod';

export const createUserAction = async (name: string, email: string) => {
  const currentUser = await getCurrentUser();

  if (!currentUser.isAdmin) {
    return {
      error: "You are not allowed to create a user",
    }
  }

  // Check if the props are correct
  const schema = z.object({
    name: z.string().min(3),
    email: z.string().email(),
  });
  
  const body = { name, email }

  const safeData = schema.tryParse(body);  

  if (!safeData.success) {
    return {
      error: "Invalid data",
    }
  }

  const data = safeData.data;

  const createUser = await prisma.user.create({
    data: {
      name: data.name,
      email: data.email,
    },
  });

  return createUser;
};

You can see that here I am checking all parameters with Zod.

3. The ultimate solution: next-safe-action

next-safe-action is a library that allows you to easily:

  1. retrieve and check that the user is logged in and has the right to perform this action
  2. check if the props are correct

You can start by installing next-safe-action:

BASH
pnpm add next-safe-action zod

Then create a file lib/action.ts:

TSX
import { createSafeActionClient } from "next-safe-action";

export const actionClient = createSafeActionClient();

Now you can use actionClient to check the parameters like this:

TSX
import { actionClient } from "~/lib/action";
import { z } from "zod";
import { getCurrentUser } from "~/lib/auth";

const Schema = z.object({
  name: z.string().min(3),
  email: z.string().email(),
});

const createUserAction = actionClient
  .schema(Schema)
  .action(async ({ parsedInput: { name, email } }) => {
    const currentUser = await getCurrentUser();

    if (!currentUser.isAdmin) {
      return {
        error: "You are not allowed to create a user",
      };
    }

    const createUser = await prisma.user.create({
      data: {
        name: name,
        email: email,
      },
    });

    return createUser;
  });

But we can go further to create sorts of "middleware" that will automatically check that the user is logged in and inject the user into the action.

To do this, we will create a method authActionClient:

TSX
import { createSafeActionClient } from "next-safe-action";

export const authActionClient = createSafeActionClient();

export const authActionClient = authActionClient.use(async ({ next }) => {
  const currentUser = await getCurrentUser();

  if (!currentUser) {
    throw new Error("You are not allowed to create a user");
  }

  return next({ ctx: { user: currentUser } });
});

Then we will be able to use it like this:

TSX
import { authActionClient } from "~/lib/action";

// ... previous code ...

const createUserAction = authActionClient
  .schema(Schema)
  .action(async ({ parsedInput: { name, email }, ctx: { user } }) => {
    if (!user.isAdmin) { 
      throw new Error("You are not allowed to create a user");
    }

    const createUser = await prisma.user.create({
      data: {
        name: name,
        email: email,
      },
    });

    return createUser;
  });

Now you are thinking that I am using throw here and it works? This is because nexts-safe-action automatically manages errors for us. When we use the methods, it should be done like this:

TSX
const handleSubmit = async (formData: FormData) => {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  const result = await createUserAction(name, email); 

  if (result?.data) { 
    alert("The user has been created");
    router.push("/users")
    return
  }

  alert(result?.serverError ?? "Error creating user");
};

Because now our server-action returns 5 values:

  • data: If the request is successful
  • serverError: If the request failed, it gives us the error
  • validationErrors: If the data passed is not valid

You should know that errors will be hidden by default; if you want to "return" errors, you will need to add handleServerError:

TS
import { createSafeActionClient } from "next-safe-action";

class ActionError extends Error { 
  constructor(message: string) { 
    super(message); 
  } 
} 

export const authActionClient = createSafeActionClient({
  handleServerError: (error) => { 
    if (error instanceof ActionError) { 
      return error.message; 
    } 

    return "Oh no, generic error";
  }
});

export const authActionClient = authActionClient.use(async ({ next }) => {
  const currentUser = await getCurrentUser();

  if (!currentUser) {
    throw new ActionError("You are not allowed to create a user"); 
  }

  return next({ ctx: { user: currentUser } });
});

In this way, we can now retrieve errors but only those we have voluntarily raised to the client!

Conclusion

Server Actions are a powerful tool to use in NextJS, but unfortunately, by default, they lack many features and security.

In this article, I showed you how to better secure your Server Actions to have a more secure and robust application using next-safe-action and zod.