next-action
v0.2.0
Published
Provides a mechanism for validate and execute server actions
Readme
next-action
Provides a centralized way to call your server actions.
Installation
npm install next-actionyarn add next-actionpnpm add next-actionbun add next-actionAPI Docs
https://neo-ciber94.github.io/next-action
Why?
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.
Table of contents
Usage
// 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 */}</>;
}Using form actions
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={...}>.
Throwing errors
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.
Map errors
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);
},
});Context
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,
},
);Middlewares
You can run a middleware before and after running your server actions.
Before server action
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:
})After server action
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`);
}
});Testing Server Actions
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_ACTIONSenvironment 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();
});See also these libraries that inspired next-action
- https://github.com/TheEdoRan/next-safe-action
- https://github.com/trpc/trpc
