Provides a centralized way to call your server actions.
npm install next-action
yarn add next-action
pnpm add next-action
bun add next-action
https://neo-ciber94.github.io/next-action
Server actions are great but have some caveats on NextJS:
- Cannot be intercepted by middlewares
- Cannot throw errors
And as any other API endpoint the user input needs to be validated.
next-action
provide an API to easily validate, throw errors and add middlewares to your server actions.
// lib/action.ts
import { createServerActionProvider } "next-action/server";
export const publicAction = createServerActionProvider();
// lib/actions/api.ts
"use server";
// Any object that have a `parse` method can be used as validator
const schema = z.object({
title: z.string(),
content: z.string(),
});
export const createPost = publicAction(
schema,
async ({ input }) => {
const postId = crypto.randomUUID();
await db.insert(posts).values({ postId, ...input });
return { postId };
});
You can call the createPost
directly client and server side as any other server action.
Client side you can also use useAction
or useFormAction
which allow you to track the loading
, error
and success
state
of the server action.
// app/create-post/page.tsx
"use client";
import { useAction } from "next-action/react";
export default function CreatePostPage() {
const {
execute,
data,
error,
status,
isExecuting,
isError,
isSuccess
} = useAction(createPost, {
onSuccess(data) {
// success
},
onError(error) {
// error
},
onSettled(result) {
// completed
},
}
);
return <>{/* Create post form */}</>;
}
You can also define and call server actions that accept a form, you define the actions using formAction
on your action provider.
'use server';
const schema = z.object({
postId: z.string()
title: z.string(),
content: z.string(),
});
export const updatePost = publicAction.formAction(
schema,
async ({ input }) => {
await db.update(posts)
.values({ postId, ...input })
.where(eq(input.postId, posts.id))
return { postId };
});
updatePost
will have the form: (input: FormData) => ActionResult<T>
, so you can use it in any form.
// app/update-post/page.tsx
"use client";
export default function UpdatePostPage() {
return (
<form action={updatePost}>
<input name="postId" />
<input name="title" />
<input name="content" />
</form>
);
}
To track the progress of a form action client side you use the useFormAction
hook.
const {
action,
data,
error,
status,
isExecuting,
isError,
isSuccess
} = useFormAction(updatePost, {
onSuccess(data) {
// success
},
onError(error) {
// error
},
onSettled(result) {
// completed
},
}
);
Then you can use the returned action
on your <form action={...}>
.
You can throw any error in your server actions, those errors will be send to the client on the result.
// lib/actions/api.ts
"use server";
import { ActionError } from "next-action";
export const deletePost = publicAction(async ({ input }) => {
throw new ActionError("Failed to delete post");
});
We recommend using ActionError
for errors you want the client to receive.
For sending the errors to the client you need to map the error to other type, by default we map it to string
,
you map your errors in the createServerActionProvider
.
import { defaultErrorMapper } from "next-action/utils";
export const publicAction = createServerActionProvider({
mapError(err: any) {
// You need to manage manually your validation errors
if (err instanceof ZodError) {
return err.issues.map((issue) => `${issue.path}: ${issue.message}`).join("\n");
}
// Use the default mappinh to string
return defaultErrorMapper(err);
},
});
You can also set a context that all your server actions will have access to.
// lib/action.ts
import { createServerActionProvider } "next-action/server";
export const action = createServerActionProvider({
context() {
return { db }
}
});
The context will be created each time the server action is called, after that you can access the context values on your server actions.
// lib/actions/api.ts
const schema = z.object({ postId: z.string() });
export const deletePost = action(
async ({ input, context }) => {
return context.db.delete(posts).where(eq(input.postId, posts.id));
},
{
validator: schema,
},
);
You can run a middleware before and after running your server actions.
import { createServerActionProvider } "next-action/server";
export const authAction = createServerActionProvider({
async onBeforeExecute({ input, context }) {
const session = await getSession();
if (!session) {
throw new ActionError("Unauthorized")
}
return { ...context, session }
}
});
You can access the new context on all your actions.
// lib/actions/api.ts
const schema = z.object({
postId: z.string(),
title: z.string(),
content: z.string(),
});
export const createPost = authAction(async ({ input, context }) => {
await db.insert(users).values({ ...input, userId: context.session.userId });
}, {
validator:
})
import { createServerActionProvider } "next-action/server";
export const authAction = createServerActionProvider({
onBeforeExecute({ input }) {
return { startTime: Date.now() }
},
onAfterExecute({ context }) {
const elapsed = Date.now() - context.startTime;
console.log(`Server action took ${elapsed}ms`);
}
});
Currently for test server actions is necessary to expose them as API endpoints, we serialize and deserialize the values in a similar way react does to ensure the same behavior.
// api/testactions/[[...testaction]]/route.ts
import { exposeServerActions } from "next-action/testing/server";
const handler = exposeServerActions({ actions: { createPost } });
export type TestActions = typeof handler.actions;
export const POST = handler;
You should set the
EXPOSE_SERVER_ACTIONS
environment variable to expose the endpoints.
And on your testing side
import { createServerActionClient } from "next-action/testing/client";
beforeAll(() => {
// Start your nextjs server
});
test("Should create post", async () => {
const client = createServerActionClient<TestActions>("http://localhost:3000/api/testactions");
const res = await client.createPost({ title: "Post 1", content: "This is my first post" });
const result = await res.json();
expect(result.success).toBeTruthy();
});