A simple React server action builder that provides input validation.
You can use any validation library that supports Standard Schema.
# npm
npm install server-act zod
# yarn
yarn add server-act zod
# pnpm
pnpm add server-act zod// action.ts
"use server";
import { serverAct } from "server-act";
import { z } from "zod";
export const sayHelloAction = serverAct
.input(
z.object({
name: z.string(),
}),
)
.action(async ({ input }) => {
return `Hello, ${input.name}`;
});// client-component.tsx
"use client";
import { sayHelloAction } from "./action";
export const ClientComponent = () => {
const onClick = () => {
const message = await sayHelloAction({ name: "John" });
console.log(message); // Hello, John
};
return (
<div>
<button onClick={onClick}>Trigger action</button>
</div>
);
};// action.ts
"use server";
import { serverAct } from "server-act";
import { z } from "zod";
export const sayHelloAction = serverAct
.use(({ next }) => {
const t = i18n();
const userId = "...";
return next({ ctx: { t, userId } });
})
.input(({ ctx }) => {
return z.object({
name: z.string().min(1, { message: ctx.t("form.name.required") }),
});
})
.action(async ({ ctx, input }) => {
console.log("User ID", ctx.userId);
return `Hello, ${input.name}`;
});You can chain multiple middlewares by calling .use(...) repeatedly.
- Middlewares run in registration order.
- Each middleware receives the current
ctxand forwards additions withnext({ ctx }). next()can be called without params when nothing needs to be added.next({ ctx })shallow-merges the provided keys into the current context.- Later middleware values override earlier values for the same key.
- Errors thrown in middleware propagate and stop later middleware from running.
// action.ts
"use server";
import { serverAct } from "server-act";
export const createGreetingAction = serverAct
.use(({ next }) =>
next({
ctx: {
requestId: crypto.randomUUID(),
role: "user",
},
}),
)
.use(({ ctx, next }) =>
next({
ctx: {
role: "admin", // overrides previous role
actorLabel: `${ctx.role}-actor`,
},
}),
)
.use(({ ctx, next }) =>
next({
ctx: {
trace: `${ctx.requestId}:${ctx.actorLabel}`,
},
}),
)
.action(async ({ ctx }) => {
return `${ctx.role} -> ${ctx.trace}`;
});.middleware() is still supported for backward compatibility, but it is deprecated in favor of .use().
const legacyAction = serverAct.middleware(({ ctx }) => ({
user: getUser(),
}));
const nextStyleAction = serverAct.use(({ ctx, next }) =>
next({
ctx: {
user: getUser(),
},
}),
);Use createServerActMiddleware to define middleware once and reuse it across actions.
import { createServerActMiddleware, serverAct } from "server-act";
const requestIdMiddleware = createServerActMiddleware(({ next }) =>
next({ ctx: { requestId: crypto.randomUUID() } }),
);
const traceMiddleware = createServerActMiddleware(({ ctx, next }) =>
next({ ctx: { trace: `${ctx.requestId}-trace` } }),
);
export const action = serverAct
.use(requestIdMiddleware)
.use(traceMiddleware)
.action(async ({ ctx }) => `${ctx.requestId}:${ctx.trace}`);
useActionStateDocumentation:
// action.ts;
"use server";
import { serverAct } from "server-act";
import { formDataToObject } from "server-act/utils";
import { z } from "zod";
function zodFormData<T extends z.ZodType>(schema: T) {
return z.preprocess<Record<string, unknown>, T, FormData>(
(v) => formDataToObject(v),
schema,
);
}
export const sayHelloAction = serverAct
.input(
zodFormData(
z.object({
name: z
.string()
.min(1, { error: `You haven't told me your name` })
.max(20, { error: "Any shorter name? You name is too long 😬" }),
}),
),
)
.stateAction(async ({ rawInput, input, inputErrors, ctx }) => {
if (inputErrors) {
return { formData: rawInput, inputErrors: inputErrors.fieldErrors };
}
return { message: `Hello, ${input.name}!` };
});The formDataToObject utility converts FormData to a structured JavaScript object, supporting nested objects, arrays, and complex form structures.
import { formDataToObject } from "server-act/utils";const formData = new FormData();
formData.append("name", "John");
const result = formDataToObject(formData);
// Result: { name: 'John' }const formData = new FormData();
formData.append("user.name", "John");
const result = formDataToObject(formData);
// Result: { user: { name: 'John' } }"use server";
import { serverAct } from "server-act";
import { formDataToObject } from "server-act/utils";
import { z } from "zod";
function zodFormData<T extends z.ZodType>(schema: T) {
return z.preprocess<Record<string, unknown>, T, FormData>(
(v) => formDataToObject(v),
schema,
);
}
export const createUserAction = serverAct
.input(
zodFormData(
z.object({
name: z.string().min(1, "Name is required"),
}),
),
)
.stateAction(async ({ rawInput, input, inputErrors }) => {
if (inputErrors) {
return { formData: rawInput, errors: inputErrors.fieldErrors };
}
// Process the validated input
console.log("User:", input.name);
return { success: true, userId: "123" };
});"use server";
import { serverAct } from "server-act";
import { formDataToObject } from "server-act/utils";
import * as v from "valibot";
export const createPostAction = serverAct
.input(
v.pipe(
v.custom<FormData>((value) => value instanceof FormData),
v.transform(formDataToObject),
v.object({
title: v.pipe(v.string(), v.minLength(1, "Title is required")),
}),
),
)
.stateAction(async ({ rawInput, input, inputErrors }) => {
if (inputErrors) {
return { formData: rawInput, errors: inputErrors.fieldErrors };
}
// Process the validated input
console.log("Post:", input.title);
return { success: true, postId: "456" };
});