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! 🎉" }
}
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! 🎉" }
})
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>
)
}
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>
)
}
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}
</>
)
}
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.