next-action

next-action

CI npm version Bundle Size docs

Provides a centralized way to call your server actions.

Installation

npm install next-action
yarn add next-action
pnpm add next-action
bun add next-action

API 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_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();
});

See also these libraries that inspired next-action