next-action-plus is a tiny TypeScript library for type-safe Next.js Server Actions.
You write a server action like you always do. You optionally add a schema. You get:
- Input validation (Zod compatible, but schema-agnostic)
- Correct
parsedInputtype inference - A native-feeling API:
.schema(...).action(async (...) => ...)
No framework lock-in. No new mental model.
Next.js Server Actions are great because they are simple: a function that runs on the server. But when you accept user input, you usually need:
- validation
- safe parsing
- accurate types in the handler
next-action-plus keeps the server action function shape and adds type-safe input parsing. You can treat your action like a normal function, and you still get strict TypeScript inference.
npm i next-action-plusIf you want Zod schemas, also install:
npm i zodimport { createActionPlus } from 'next-action-plus';
import { z } from 'zod';
export const sayHello = createActionPlus()
.schema(z.object({ name: z.string().min(1) }))
.action(async ({ parsedInput }) => {
return { message: `Hello ${parsedInput.name}` };
});sayHellois still “just a function” you can call.parsedInputis inferred from your schema.- The return type is inferred from your handler.
If schema validation fails, the action throws an Error.
The error message is intentionally short and looks like:
Input (name) is error: String must contain at least 1 character(s)Notes:
- Only the first validation issue is used to build the message.
- The thrown error is an
ActionPlusValidationError(extendsError) with a developer-friendly payload. - The original validator error is preserved on
error.cause. - Normalized issues are available on
error.issues.
try {
await sayHello({ name: '' });
} catch (error) {
// error.message => "Input (name) is error: ..."
// (error as ActionPlusValidationError).code => "VALIDATION_ERROR"
// (error as ActionPlusValidationError).issues => [{ path, message, raw }]
// (error as any).cause => original validator error (e.g. ZodError)
}createActionPlus accepts options to control logging and customize thrown errors.
import { createActionPlus } from 'next-action-plus';
export const client = createActionPlus({
logger: false,
formatValidationError: ({ message, issues, error }) => {
const e = new Error(message);
(e as any).issues = issues;
(e as any).cause = error;
return e;
},
onError: ({ phase, error }) => {
// report errors (Sentry, etc)
// phase: "validation" | "middleware" | "handler"
void error;
},
});This keeps the native Server Action feel.
import 'server-only';
import { createActionPlus } from 'next-action-plus';
import { z } from 'zod';
export const updateProfile = createActionPlus()
.schema(z.object({ displayName: z.string().min(2) }))
.action(async ({ parsedInput }) => {
// parsedInput.displayName is string
return { ok: true };
});You can import a Server Action into a Client Component and call it like a normal async function. Next.js runs it on the server.
'use client';
import { useState, useTransition } from 'react';
import { sayHello } from '@/app/actions';
export function SayHelloClient() {
const [name, setName] = useState('');
const [message, setMessage] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} placeholder='Ada' />
<button
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await sayHello({ name });
setMessage(result.message);
})
}
>
{pending ? 'Sending…' : 'Say hello'}
</button>
{message ? <p>{message}</p> : null}
</div>
);
}The snippets above are the full examples.
Works with FormData and File using zod-form-data.
If you chain multiple schemas and the input is a FormData, next-action-plus will first try to find a schema that can parse the FormData into a plain object, then validate the remaining schemas against that object.
import { createActionPlus } from 'next-action-plus';
import { zfd } from 'zod-form-data';
export const uploadAvatar = createActionPlus()
.schema(
zfd.formData({
avatar: zfd.file(),
}),
)
.action(async ({ parsedInput }) => {
// parsedInput.avatar is File
return { filename: parsedInput.avatar.name };
});Add data to context in a type-safe way.
Validation runs first, then middleware runs, then your handler runs.
import { createActionPlus } from 'next-action-plus';
import { z } from 'zod';
const client = createActionPlus().use(async ({ next }) => next({ ctx: { userId: 'u_123' } }));
export const deletePost = client.schema(z.object({ postId: z.string() })).action(async ({ parsedInput, ctx }) => {
// ctx.userId is string
// parsedInput.postId is string
return { ok: true };
});You are not forced into one validator.
Supported schema shapes:
- Zod (
parse/parseAsync) - Generic
{ parse(...) }and{ parseAsync(...) } - Standard Schema v1 (
~standard.validate)
If you already have a schema system, you can plug it in.
You can chain multiple schemas. If they return objects, outputs are merged.
import { createActionPlus } from 'next-action-plus';
const s1 = { parse: (_: unknown) => ({ a: 'a' }) };
const s2 = { parse: (_: unknown) => ({ b: 2 }) };
export const demo = createActionPlus()
.schema(s1)
.schema(s2)
.action(async ({ parsedInput }) => {
// parsedInput is { a: string; b: number }
return parsedInput;
});No. It is built for the Server Actions style, but it runs in any Node 20+ runtime.
No. The API is intentionally small: createActionPlus() → .schema() → .use() → .action().
Yes. Use zod-form-data (see FormData + File uploads).
No. The returned action function keeps the exact return type of your handler.
Yes. Multiple schemas can validate the same base input. If they produce objects, outputs are merged.
This repository ships with semantic-release.
- Push Conventional Commits to
main - GitHub Actions runs
npm test,npm run build, then publishes