codelynx.dev
🇫🇷🇬🇧

Back • 13/10/2024

How to Use Next-Safe-Action for Your Server Actions

Written by Melvyn Malherbe on 13/10/2024


Server Actions in NextJS are powerful, but they lack essential features:

  • Middleware for user rights verification, logging, and more
  • Error management
  • Parameter validation

To address this issue, an open-source library next-safe-action has become very popular, and I will show you exactly how it can solve all your problems.

Installation of Next-Safe-Action

To install next-safe-action, simply run the following command:

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();

This actionClient will then allow you to create server actions more easily. Imagine we want to create a Server Action to update a user; we can now do:

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

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

const UpdateUserSchema = z.object({
  id: z.string(),
  name: z.string().optional(),
  email: z.string().optional(),
});

export const updateUserAction = actionClient
  .schema(UpdateUserSchema)
  .action(async ({ parsedInput: { id, name, email } }) => {
    const currentUser = await getCurrentUser();

    if (currentUser.id !== id) {
      throw new Error("You are not allowed to update this user");
    }

    const updatedUser = await prisma.user.update({
      where: {
        id,
      },
      data: {
        name: name ?? currentUser.name,
        email: email ?? currentUser.email,
      },
    });

    return updatedUser;
  });

You can see here that parsedInput contains the data defined by the Schema. Everything is automatically TypeSafe, and no further verification is needed.

You can then use your action in this way:

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

  const result = await updateUserAction({
    id: params.id,
    name,
    email,
  });
  
  // If result?.data is defined, the action is successful
  if (result?.data) {
    alert("The user has been updated");
    router.push("/users");
    return;
  }

  // If result?.serverError is defined, the action failed
  if (result?.serverError) {
    alert(result.serverError ?? "Error updating user");
    return;
  }
};

Adding Middleware

It will be very common to perform checks before executing an action. For example, we can check if the user is logged in and has the rights to perform this action.

For this, we will use next-safe-action and create middleware that will check if the user is logged in and has the rights to perform this action.

TSX
// lib/action.ts

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

export const actionClient = createSafeActionClient();

export const authActionClient = actionClient.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 } });
});

Now we can create an action and retrieve the current user. We are sure that this user is the current user thanks to the middleware we created with "use":

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

/// ...

export const updateUserAction = authActionClient
  .schema(UpdateUserSchema)
  .action(async ({ parsedInput: { id, name, email }, ctx: { user } }) => {
    if (user.id !== id) {
      throw new Error("You are not allowed to update this user");
    }

    // ...
  });

Error Management

But the problem we have is that the default Error is not returned to the client for security reasons. We will need to define ourselves the Errors that we want to return to the client.

For this, we will use the handleServerError method:

TSX
// lib/action.ts

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

export class ActionError extends Error { } 

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

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

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

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

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

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

// ...
export const updateUserAction = authActionClient
  .schema(UpdateUserSchema)
  .action(async ({ parsedInput: { id, name, email }, ctx: { user } }) => {
    if (user.id !== id) {
      throw new Error("You are not allowed to update this user"); 
      throw new ActionError("You are not allowed to update this user"); 
    }
    // ...
  });

This way, this error will be returned in result?.serverError when the action failed due to these errors.

Retrieving the Action Result

The code to retrieve the action result can be complicated and causes me a lot of problems.

I created a utility that allows you to quickly determine if the action is successful or failed:

TSX
/**
 * Determines if a server action is successful or not
 * A server action is successful if it has a data property and no serverError property
 *
 * @param action Return value of a server action
 * @returns A boolean indicating if the action is successful
 */
export const isActionSuccessful = <T extends z.ZodType>(
  action?: SafeActionResult<string, T, readonly [], any, any>
): action is { data: T; serverError: undefined; validationError: undefined } => {
  if (!action) {
    return false;
  }

  if (action.serverError) {
    return false;
  }

  if (action.validationErrors) {
    return false;
  }

  return true;
};

Thanks to is, we are saying:

If this method returns true, then we know for sure that data is defined

You can then use this method in this way:

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

  const result = await updateUserAction({
    id: params.id,
    name,
    email,
  });

  if (!isActionSuccessful(result)) {
    alert(result.serverError ?? "Error updating user");
    return;
  }

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

You can see that this is already cleaner and makes it easier to know the result of the action.

But sometimes we want to go even further... for example, if you use Tanstack Query to manage backend requests.

What Tanstack Query likes is to have a promise, the thing is that server-action returned by next-safe-action is always resolved.

However, sometimes we want them to be rejected when the action fails.

For this, I created this utility:

TS
/**
 * Converts an action result to a promise that resolves to false
 *
 * @param action Return value of a server action
 * @returns A promise that resolves to false
 */
export const resolveActionResult = async <T extends z.ZodType>(
  action: Promise<SafeActionResult<string, T, readonly [], any, any> | undefined>
): Promise<T> => {
  return new Promise((resolve, reject) => {
    action
      .then((result) => {
        if (isActionSuccessful(result)) {
          resolve(result.data);
        } else {
          reject(result?.serverError ?? 'Something went wrong');
        }
      })
      .catch((error) => {
        reject(error);
      });
  });
};

This resolveActionResult method will transform our action into a Promise that resolves when the action works and rejects when the action fails.

You can use this method in this way:

TSX
export const mutation = useMutation({
  mutationFn: async (props: { email: string, name: string }) => {
    return resolveActionResult(updateUserAction({
      ...props,
      id: params.id
    }));
  },
  onError: (error) => {
    alert(error);
  },
  onSuccess: (data) => {
    alert("The user has been updated");
    router.push("/users");
  },
});

Isn't it beautiful?

Creating Configurable Middleware

Let's say your users have permissions, which are in the form of strings in a permissions array. How do you create actions that automatically check these permissions? For this, we will use metadata.

Here is an example of code:

TSX
// lib/action.ts

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

export const actionClient = createSafeActionClient({
  handleServerError: (error) => {
    // ...
  },
  defineMetadataSchema() {
    return z.object({
      role: z.string()
    });
  },
});

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

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

  if (!currentUser.roles.includes(metadata.role)) {
    throw new Error("You are not allowed to do this action.");
  }

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

Here we define a metadata schema that will contain the user's permissions. We can then use this metadata in the middleware to check if the user has the rights to perform this action.

We can then create secure server-actions in this way:

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

import { authActionClient } from '~/lib/action';
import { z } from 'zod';

const UpdateUserSchema = z.object({
  id: z.string(),
  name: z.string().optional(),
  email: z.string().optional(),
});

export const updateUserAction = authActionClient
  .schema(UpdateUserSchema)
  .metadata({
    role: "admin"
  })
  .action(async ({ parsedInput: { id, name, email }, ctx: { user } }) => {
    const updatedUser = await prisma.user.update({
      where: {
        id,
      },
      data: {
        name: name ?? user.name,
        email: email ?? user.email,
      },
    });

    return updatedUser;
  });

Here we automatically check that the user is an administrator before performing the action.

I recommend using these methods with permissions containing strings with the different available rights.

Conclusions

I have presented everything I know about this next-safe-action library, which can truly change the way you do NextJS.