Efficient Type-Safe Server Actions in Next.js with ZSA

Efficient Type-Safe Server Actions in Next.js with ZSA

·

4 min read

TL;DR: Discover how to implement type-safe server actions in your Next.js application using zsa or next-safe-action. This guide focuses on zsa, offering a clear approach to integrating type safety, validation, and error handling.

Introduction to Type-Safe Server Actions in Next.js

In modern web development with Next.js, server actions are essential for handling form submissions and data mutations. While implementing these actions with robust type safety and validation can be challenging, utilizing libraries like zsa or next-safe-action can significantly simplify this process, enhancing both security and developer experience.

What is ZSA and Why Use It?

ZSA is a library that integrates with Zod for schema validation in Next.js. It simplifies the creation of type-safe server actions and provides built-in support for handling input validation and error management.

By using zsa, you can avoid the complexity of manually setting up validation and error-handling utilities, streamlining your development process and improving code quality.

Enough theory! Now, I’ll walk you through how I used this with my Next.js blog application.

Set Up Your Service Logic

Normally, we create an actions.ts file inside a lib folder. However, to keep things cleaner, I’ll create a service folder as well, where we’ll create a service.ts file to handle the core logic for server-side operations. In this example, we’ll use Prisma ORM with PostgreSQL.

// services/postService.ts
import { prisma } from '@/lib/prisma';

export async function savePostToDb(input: PostInput) {
  await prisma.post.create({
    data: {
      title: input.title,
      images: input.images,
    },
  });
}

Define Server Actions and Middleware

Authentication Procedure

In the action file where the action and ZSA logic take place, since our app has different roles such as admin and user, we need middleware to allow only admins to perform certain server actions. This can be achieved by creating procedures using ZSA.

First, we can create a procedure for authenticated users.

//admin/action.ts
"use server" 
import { createServerAction, createServerActionProcedure } from 'zsa';
import { getUser } from '@/lib/lucia';

const authedProcedure = createServerActionProcedure().handler(async () => {
  try {
    const user = await getUser();

    if (!user) {
      throw new Error('User not authenticated');
    }

    return {
      user: {
        email: user.email,
        id: user.id,
        role: user.role
      }
    };
  } catch {
    throw new Error('User not authenticated');
  }
});

Admin Check Procedure

Extend the authentication procedure to include admin role verification:

//admin/action.ts
"use server" 

import { createServerAction, createServerActionProcedure } from 'zsa';

const isAdminProcedure = createServerActionProcedure(authedProcedure).handler(
  async ({ ctx }) => {
    const role = ctx.user.role;

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

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

Perfect, we have created a middleware that performs the isAdmin check, which we can chain with admin-only server actions.

//admin/action.ts
"use server" 
import { createServerAction, createServerActionProcedure } from 'zsa';
import {
  POST_SCHEMA
} from '@/lib/validations';

// Only admins are allowed to create a post.
export const createPost = isAdminProcedure
  .createServerAction()
  .input(POST_SCHEMA, {
    type: 'formData'
  })
  .handler(async ({ input, ctx }) => {
    await savePostToDb(input);
    revalidatePath('/', 'layout');
    redirect('/admin/dashboard/posts');
  });

Now, we have successfully created a type-safe server action with the Zod schema for input validation, both on the server and the client.

On to the client section. To use the server action, there are several approaches outlined in the ZSA documentation. For now, I’m using my favorite approach.

//form.tsx
"use client"
import { useServerAction } from 'zsa-react';

// you can use this hook inside the form function component just like how you do mutation with the react query.
 const { execute: createPostExe, isPending: loadingCreate } = useServerAction(
    createPost,
    {
      onSuccess: () => {
        toast.success('Post created successfully');
        form.reset();
      },
      onError: ({ err }) => {
        toast.error(err.message);
      }
    }
  );

 const onSubmit = async (values: POST_SCHEMA_VALUES) => {
    //! Files need to be sent as formData.
    const formData = new FormData();
    formData.append('title', values.title);
    for (let i = 0; i < values.images.length; i++) {
      formData.append('images', values.images[i], values.images[i].name);
    }
    const { images, ...rest } = values;
    await createPostExe(formData, rest);
  };

// rest of code...
<form onSubmit={form.handleSubmit(onSubmit)}
 className="w-full space-y-8">

If you are familiar with React Query mutations, this syntax will be familiar to you. This is how you can use it if you are using React Hook Form to validate input with the Zod schema on the client side. ZSA provides not only validation errors from the Zod schema but also server and fetch errors.

That’s it! Now you have successfully created and integrated a type-safe server action with proper error handling and validation. This is how I’m managing server actions for my recent projects.

The next-safe-action library also performs similar tasks and follows a similar syntax, so it doesn’t matter which one you choose. One thing I noticed is that if you are using server actions to fetch data in client components along with React Query, ZSA has a wrapper on top of React Query that you can use to fetch data on the client side. You can find more details in the zsa documentation

Kudos to the creators and collaborators of both packages! Feel free to give them a star on GitHub.

Happy Coding! 🥂