DEV Community

Cover image for Next.js Server Actions with next-safe-action
Dave Gray
Dave Gray

Posted on • Originally published at davegray.codes on

Next.js Server Actions with next-safe-action

TLDR: Add type safe and validated server actions to your Next.js App Router project with next-safe-action.

Next.js Server Actions

Server Actions are asynchronous functions executed on the server in Next.js.

They are defined with the "use server" directive and can be used
in both server and client components for handling form submissions and data mutations.

Over the last year, I've seen them applied in a variety of ways, and I've used them in projects myself, too.

Now, I have recently discovered the next-safe-action library, and I like the structure, ease-of-use, and extra features it provides.

An Example Server Action without next-safe-action

I think the best way to show why I like next-safe-action is to show how I implemented a Next.js server action without the library first.

Afterwards, I will show the refactor with next-safe-action.

Here's an example server action from a repository and tutorial I recently published on creating a Next.js Modal Form with react-hook-form, ShadCN/ui, Server Actions and Zod validation.

// src/app/actions/actions.ts
"use server"

import { UserSchema } from "@/schemas/User"
import type { User } from "@/schemas/User"

type ReturnType = {
    message: string,
    errors?: Record<string, unknown>
}

export async function saveUser(user: User): Promise<ReturnType> {

    // Check valid login here

    const parsed = UserSchema.safeParse(user)

    if (!parsed.success) {
        return {
            message: "Submission Failed",
            errors: parsed.error.flatten().fieldErrors
        }
    }

    await fetch(`http://localhost:3500/users/${user.id}`, {
        method: 'PATCH',
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify({
            firstname: user.firstname,
            lastname: user.lastname,
            email: user.email,
        })
    })

    return { message: "User Updated! πŸŽ‰" }
}
Enter fullscreen mode Exit fullscreen mode

You can see above that I was using a local json-server instance in the tutorial to update user data.

Before the update occurs, the data is validated with Zod.

If the validation fails, Zod validation errors are sent back to the client component with the ZodError type flatten method applied.

Now let's compare to the refactored version using next-safe-action.

An Example Server Action with next-safe-action

// src/app/actions/actions.ts
"use server"

import { UserSchema } from "@/schemas/User"
import { actionClient } from "@/lib/safe-action"
import { flattenValidationErrors } from "next-safe-action"

export const saveUserAction = actionClient
    .schema(UserSchema, {
        handleValidationErrorsShape: (ve) => flattenValidationErrors(ve).fieldErrors,
    })
    .action(async ({ parsedInput: { id, firstname, lastname, email } }) => {

        // Check valid login here

        await fetch(`http://localhost:3500/users/${id}`, {
            method: 'PATCH',
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                firstname: firstname,
                lastname: lastname,
                email: email,
            })
        })

        return { message: "User Updated! πŸŽ‰" }
    })
Enter fullscreen mode Exit fullscreen mode

The file has shrunk down from 33 lines to 26 lines of code.

Starting at the top you can see I still import the Zod UserSchema I have defined. The inferred User type is no longer imported.

New imports include actionClient and flattenValidationErrors.

Instead of export async function, I'm using export const and starting the definition of saveUserAction with the actionClient.

I chain the schema method to the actionClient while passing in the UserSchema. I also set the handleValidationErrorsShape option to use the imported flattenValidationErrors method. This method is similar to the ZodError type method flatten that I used in the original function.

Next, I chain the action method and call the async function inside of it. It supplies a parsedInput prop. I destructure the prop to get the input data sent to the server action.

The remainder of the function remains unchanged.

Note that in this refactored version I did not define a ReturnType.

The return type is the result defined by the useAction hook return object. I apply the useAction hook in the client component.

While some overhead is saved in the server action code you see above, even more is saved in the client component.

Below, I again show before and after code versions. This time the before and afters are of the client component using react-hook-form.

An Example Client Component without next-safe-action

// src/app/edit/[id]/UserForm.tsx
"use client"

import { useForm } from "react-hook-form"
import { Form } from "@/components/ui/form"
import { Button } from "@/components/ui/button"
import { InputWithLabel } from "@/components/InputWithLabel"
import { zodResolver } from "@hookform/resolvers/zod"
import { UserSchema } from "@/schemas/User"
import type { User } from "@/schemas/User"
import { saveUser } from "@/app/actions/actions"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"

type Props = {
    user: User
}

export default function UserForm({ user }: Props) {
    const [message, setMessage] = useState('')
    const [errors, setErrors] = useState({})
    const router = useRouter()

    const form = useForm<User>({
        mode: 'onBlur',
        resolver: zodResolver(UserSchema),
        defaultValues: { ...user },
    })

    useEffect(() => {
        // boolean to indicate if form has not been saved
        localStorage.setItem("userFormModified", form.formState.isDirty.toString())
    }, [form.formState.isDirty])

    async function onSubmit() {
        setMessage('')
        setErrors({})
        /* No need to validate here because 
        react-hook-form already validates with 
        the Zod schema */
        const result = await saveUser(form.getValues())
        if (result?.errors) {
            setMessage(result.message)
            setErrors(result.errors)
            return
        } else {
            setMessage(result.message)
            // update client-side cache
            router.refresh() 
            // reset dirty fields
            form.reset(form.getValues())
        }
    }

    return (
        <div>
            {message ? (
                <h2 className="text-2xl">{message}</h2>
            ) : null}

            {errors ? (
                <div className="mb-10 text-red-500">
                    {Object.keys(errors).map(key => (
                        <p key={key}>{`${key}: ${errors[key as keyof typeof errors]}`}</p>
                    ))}
                </div>
            ) : null}

            <Form {...form}>
                <form onSubmit={(e) => {
                    e.preventDefault()
                    form.handleSubmit(onSubmit)();
                }} className="flex flex-col gap-4">

                    <InputWithLabel
                        fieldTitle="First Name"
                        nameInSchema="firstname"
                    />
                    <InputWithLabel
                        fieldTitle="Last Name"
                        nameInSchema="lastname"
                    />
                    <InputWithLabel
                        fieldTitle="Email"
                        nameInSchema="email"
                    />
                    <div className="flex gap-4">
                        <Button>Submit</Button>
                        <Button
                            type="button"
                            variant="destructive"
                            onClick={() => form.reset()}
                        >Reset</Button>
                    </div>
                </form>
            </Form>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

In the above example, I had to set state for both the message and errors that the original server action could return.

I also needed to consider that state in the onSubmit function.

In the refactored version below, you can see how this is simplified.

An Example Client Component with next-safe-action

// src/app/edit/[id]/UserForm.tsx
"use client"

import { useForm } from "react-hook-form"
import { Form } from "@/components/ui/form"
import { Button } from "@/components/ui/button"
import { InputWithLabel } from "@/components/InputWithLabel"
import { zodResolver } from "@hookform/resolvers/zod"
import { UserSchema } from "@/schemas/User"
import type { User } from "@/schemas/User"
import { saveUserAction } from "@/app/actions/actions"
import { useEffect } from "react"
import { useRouter } from "next/navigation"

import { useAction } from "next-safe-action/hooks"
import { DisplayServerActionResponse } from "@/components/DisplayServerActionResponse"

type Props = {
    user: User
}

export default function UserForm({ user }: Props) {
    const router = useRouter()
    const { execute, result, isExecuting } = useAction(saveUserAction)

    const form = useForm<User>({
        resolver: zodResolver(UserSchema),
        defaultValues: { ...user },
    })

    useEffect(() => {
        // boolean to indicate if form has not been saved
        localStorage.setItem("userFormModified", form.formState.isDirty.toString())
    }, [form.formState.isDirty])

    async function onSubmit() {
        /* No need to validate here because 
        react-hook-form already validates with 
        the Zod schema */
        execute(form.getValues())
        // update client-side cache
        router.refresh()
        // reset dirty fields
        form.reset(form.getValues())
    }

    return (
        <div>
            <DisplayServerActionResponse result={result} />

            <Form {...form}>
                <form onSubmit={(e) => {
                    e.preventDefault()
                    form.handleSubmit(onSubmit)();
                }} className="flex flex-col gap-4">

                    <InputWithLabel
                        fieldTitle="First Name"
                        nameInSchema="firstname"
                    />
                    <InputWithLabel
                        fieldTitle="Last Name"
                        nameInSchema="lastname"
                    />
                    <InputWithLabel
                        fieldTitle="Email"
                        nameInSchema="email"
                    />
                    <div className="flex gap-4">
                        <Button>{isExecuting ? "Working..." : "Submit"}</Button>
                        <Button
                            type="button"
                            variant="destructive"
                            onClick={() => form.reset()}
                        >Reset</Button>
                    </div>
                </form>
            </Form>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

In this refactored version, I imported the useAction hook supplied by next-safe-action and a custom component I created called DisplayServerActionResponse.

I eliminated all usage of useState.

DisplayServerActionResponse receives the result that is provided by the useAction hook. It holds the data sent back from the server action.

useAction also provides an execute function and an isExecuting boolean. (Check the docs for what else it can provide, too.)

All of this greatly reduces the logic I needed to put in the onSubmit function.

Receiving the result from the server action makes it easy to abstract the displayed response to the custom DisplayServerActionResponse component, too.

Here's a quick look at that component as well..

Displaying the Server Action Result

type Props = {
    result: {
        data?: {
            message?: string,
        },
        serverError?: string,
        fetchError?: string,
        validationErrors?: Record<string, string[] | undefined> | undefined,
    }
}

export function DisplayServerActionResponse({ result }: Props) {

    const { data, serverError, fetchError, validationErrors } = result

    return (
        <>
            {/* Success Message */}
            {data?.message ? (
                <h2 className="text-2xl my-2">{data.message}</h2>
            ) : null}

            {serverError ? (
                <div className="my-2 text-red-500">
                    <p>{serverError}</p>
                </div>
            ) : null}

            {fetchError ? (
                <div className="my-2 text-red-500">
                    <p>{fetchError}</p>
                </div>
            ) : null}

            {validationErrors ? (
                <div className="my-2 text-red-500">
                    {Object.keys(validationErrors).map(key => (
                        <p key={key}>{`${key}: ${validationErrors && validationErrors[key as keyof typeof validationErrors]}`}</p>
                    ))}
                </div>
            ) : null}
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

Above, you can see that next-safe-action provides not only validation errors from the Zod schema I constructed, but it also provides server errors and fetch errors.

In addition, the result object contains the success message I provided from the server action.

Learn More

This is just one example and a simple one at that! Dive into the docs and solve your own specific use case to see what else next-safe-action is capable of.

I plan to refactor my old server actions and use next-safe-action going forward.


Let's Connect!

Hi, I'm Dave. I work as a full-time developer, instructor and creator.

If you enjoyed this article, you might enjoy my other content, too.

My Stuff: Courses, Cheat Sheets, Roadmaps

My Blog: davegray.codes

YouTube: @davegrayteachescode

X: @yesdavidgray

GitHub: gitdagray

LinkedIn: /in/davidagray

Patreon: Join my Support Team!

Buy Me A Coffee: You will have my sincere gratitude

Thank you for joining me on this journey.

Dave

Top comments (0)