Procedures

Procedures allow you to add additional context to a set of server actions, such as the userId of the caller. They are useful for ensuring certain actions can only be called if specific conditions are met, like the user being logged in or having certain permissions.

Creating a basic procedure

Here is an example of a simple procedure that ensures a user is logged in, and passes that information to the ctx of the handler

actions.ts
"use server"
import { createServerActionProcedure } from "zsa"


const authedProcedure = createServerActionProcedure() 
  .handler(async () => {
    try {
      const { email, id } = await getUser();

      return {
        user: {
          email,
          id,
        }
      }
    } catch {
      throw new Error("User not authenticated")
    }
  })

export const updateEmail = authedProcedure 
  .createServerAction()  
  .input(z.object({
    newEmail: z.string()
  })).handler(async ({input, ctx}) => {
    const {user} = ctx

    // Update user's email in the database
    await db.update(users).set({
      email: newEmail,
    }).where(eq(users.id, user.id))

    return input.newEmail
  })

In this example:

  1. We create a authedProcedure that verifies the user is logged in by calling getUser().
  2. If successful, it returns the user's email and id. If not, it throws an error.
  3. We create an updateEmail by chaining createServerAction() off the procedure. Invoking this action will now call the procedure's handler before running its own handler, thus ensuring that only authed users can use this server action.

Chaining procedures

What if you need to restrict an action based on additional conditions, like the user being an admin? You can create an chained procedure that to run a procedure AFTER another procedure and feed forward that ctx.

actions.ts
const isAdminProcedure = createServerActionProcedure(authedProcedure) 
  .handler(async ({ ctx }) => {
    const role = getUserRole(ctx.user.id)

    if (role !== "admin") {
      throw new Error("User is not an admin")
    }

    return {
      user: {
          id: ctx.user.id,
          email: ctx.user.email,
          role: role
      }
    }
});

const deleteUser = isAdminProcedure 
  .createServerAction()
  .input(z.object({
    userIdToDelete: z.string()
  })).handler(async ({ input, ctx }) => {
    const { userIdToDelete } = input
    const { user } = ctx // receive the context from the procedures

    // Delete user from database
    await db.delete(users).where(eq(users.id, userIdToDelete));

    return userIdToDelete;
});

In this example:

  1. We call createServerActionProcedure and chain off of authedProcedure by passing it as an argument.
  2. We use the ctx from the authedProcedure's handler and further determine if the user is an admin.
  3. We create a deleteUser action that runs the two procedures, in order before the actions handler and also has access to the isAdminProcedure's return value in the ctx. The action will now only run it's handler if the user is an admin and the inital procedure's don't throw an error.

Procedures with input

You can also create procedures that require certain input, such as requiring a postId, and validate that the user has access to that resource:

actions.ts
const ownsPostProcedure = createServerActionProcedure(isAdminProcedure) 
    .input(
        z.object({ postId: z.string() })
    )
    .handler(async ({ input, ctx }) => {

        //validate post ownership
        const ownsPost = await checkUserOwnsPost(ctx.user.id, input.postId)

        if (!ownsPost) {
            throw new Error("UNAUTHORIZED")
        }

        return {
            user: ctx.user,
            post: {
                id: input.postId,
            },
        }
})

Actions using this ownsPostProcedure will now always require a postId in the input. It will now validate the user's ownership before executing the action's handler:

actions.ts
const updatePostName = ownsPostProcedure 
  .createServerAction()
  .input(z.object({ newPostName: z.string() }))
  .handler(async ({ input, ctx }) => {

    console.log({
      // input contains postId and newPostName
      newPostName: input.newPostName, 
      postId: input.postId, 
      // ctx contains user and post returned by procedures
      user: ctx.user, 
      post: ctx.post, 
    })

    return "GREAT SUCCESS"
  })

Now, when we call the updatePostName, both postId and newPostName will be required in the input.

const [data, err] = await updatePostName({
  newPostName: "hello world",
  postId: "post_id_123", 
})

Chaining procedures is a powerful way to pass context into your actions and ensure that certain conditions are met before running the action's handler.