
Next Action Handler
- 3 installs
- 8 repo stars
- Updated May 30, 2026
- mohamed-hossam1/nextjs-skills
Install and use a Next.js server-action layer built on next-safe-action, better-auth, pino, and zod with standardized errors, logging, and auth.
About
Sets up and uses next-action-handler, a server-action layer that standardizes errors, logging, and auth context via next-safe-action, better-auth, pino, and zod. A developer uses it to scaffold action clients, required actionName metadata, and authed actions in a Next.js app.
- npx installer wires safe-action.ts, error classes, and pino logging
- authedActionClient requires a requireUser auth-helper and actionName metadata
Next Action Handler by the numbers
- 3 all-time installs (skills.sh)
- Ranked #3,739 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mohamed-hossam1/nextjs-skills --skill next-action-handlerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 8 |
| Last updated | May 30, 2026 |
| Repository | mohamed-hossam1/nextjs-skills ↗ |
What it does
Install and use a Next.js server-action layer built on next-safe-action, better-auth, pino, and zod with standardized errors, logging, and auth.
Files
next-action-handler Skill
What It Does
next-action-handler installs a server action layer built on next-safe-action, better-auth, pino, and zod. It standardizes errors, logging, and auth context.
Installation
If you want a local dev dependency, install it first. Otherwise skip to Usage.
npm install -D next-action-handlerUsage
From the project root, run the installer:
npx next-action-handler@latest add@latest forces npx to use the newest published version. The installer applies the full handler setup in one pass, with no component selection.
Install path detection order:
1. lib/ -> lib/next-action-handler/ 2. app/lib/ -> app/lib/next-action-handler/ 3. Otherwise create lib/next-action-handler/
Dependencies installed: better-auth, next-safe-action, pino, pino-pretty, server-only, zod.
Required: auth-helpers.ts
safe-action.ts imports requireUser from ../auth-helpers. Create it before using authedActionClient.
// Good: required for authedActionClient
import { headers } from "next/headers";
import { auth } from "./auth";
import { UnauthorizedError } from "./next-action-handler/error/errors";
export async function requireUser() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) throw new UnauthorizedError("You must be logged in");
return session.user;
}Action Clients and Metadata
Every action must call .metadata({ actionName }). The metadata schema requires it and the logger uses it.
// Good: metadata actionName is required
import { z } from "zod";
import {
actionClient,
authedActionClient,
} from "@/lib/next-action-handler/safe-action";
import { db } from "@/lib/db";
export const submitContactForm = actionClient
.metadata({ actionName: "submitContactForm" })
.inputSchema(
z.object({ email: z.string().email(), message: z.string().min(1) }),
)
.action(async ({ parsedInput }) => {
return { success: true, email: parsedInput.email };
});
export const updateProfile = authedActionClient
.metadata({ actionName: "updateProfile" })
.inputSchema(z.object({ displayName: z.string().min(1) }))
.action(async ({ parsedInput, ctx }) => {
await db.users.update({
id: ctx.user.id,
displayName: parsedInput.displayName,
});
return { updated: true };
});Result Shape and Input Validation
actionClient returns a SafeActionResult union. Only one of data, serverError, or validationErrors is present.
// Good: result union shape
type SafeActionResult<ServerError, Schema, ShapedErrors, Data> =
| { data: Data; serverError?: undefined; validationErrors?: undefined }
| { data?: undefined; serverError: ServerError; validationErrors?: undefined }
| {
data?: undefined;
serverError?: undefined;
validationErrors: ShapedErrors;
};Input schema failures land in validationErrors, not serverError.
// Good: check validationErrors before serverError
import { z } from "zod";
import { actionClient } from "@/lib/next-action-handler/safe-action";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8, "Password must contain at least 8 characters"),
});
export const loginAction = actionClient
.metadata({ actionName: "loginAction" })
.inputSchema(schema)
.action(async ({ parsedInput }) => {
return { success: true, email: parsedInput.email };
});
export async function submitLogin() {
const result = await loginAction({ email: "bad", password: "short" });
if (result.validationErrors) {
console.error(result.validationErrors.email?._errors?.[0]);
return;
}
if (result.serverError) {
console.error(result.serverError.message);
return;
}
console.log(result.data.email);
}Output Validation
outputSchema mismatches become serverError (not validationErrors). Output validation failures are detected as ActionOutputDataValidationError and mapped to a PublicServerError with code INTERNAL_SERVER_ERROR and message Unexpected response. Please try again. The error is normalized and logged using InternalServerError("Action output validation failed", error).
handleServerError returns a PublicServerError shape { code, message } and uses DEFAULT_SERVER_ERROR_MESSAGE when expose is false for all other errors.
// Good: output validation returns serverError
import { z } from "zod";
import { actionClient } from "@/lib/next-action-handler/safe-action";
import { db } from "@/lib/db";
const outputSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
export const getUser = actionClient
.metadata({ actionName: "getUser" })
.inputSchema(z.object({ userId: z.string() }))
.outputSchema(outputSchema)
.action(async ({ parsedInput }) => {
const user = await db.user.findUnique({
where: { id: parsedInput.userId },
});
return { id: user.id, name: user.name, email: user.email };
});If you customize handleServerError, keep the ActionOutputDataValidationError branch from safe-action.ts. See patterns for a copy-paste block.
Error Classes
Throw these inside actions. They are normalized and logged; only safe messages reach the client.
| Class | Code | Expose | Default message |
|---|---|---|---|
BadRequestError | BAD_REQUEST | Yes | "Bad request" |
ValidationError | VALIDATION_ERROR | Yes | "Invalid input" |
UnauthorizedError | UNAUTHORIZED | Yes | "Unauthorized" |
ForbiddenError | FORBIDDEN | Yes | "Forbidden" |
NotFoundError | NOT_FOUND | Yes | "Resource not found" |
RateLimitError | RATE_LIMITED | Yes | "Too many requests" |
DatabaseError | DATABASE_ERROR | No | "Database operation failed" |
InternalServerError | INTERNAL_SERVER_ERROR | No | "Something went wrong" |
Error Converters
Use helpers when calling better-auth or database APIs that throw.
// Good: convert external errors into ActionError subclasses
import { fromBetterAuthError } from "@/lib/next-action-handler/error/better-auth-error";
import { toDatabaseError } from "@/lib/next-action-handler/error/database-error";
export function toAuthError(error: unknown) {
return fromBetterAuthError(error, {
enumerationSafe: true,
genericMessage: "Invalid credentials",
});
}
export function toDbError(error: unknown) {
return toDatabaseError(error, "Database operation failed");
}Logging
Logging is automatic. logActionExecution runs only when there is no serverError. logActionError runs for all errors.
References
- Copy-paste patterns: references/patterns.md
next-action-handler: Copy-Paste Patterns
1. Full server action + client component
Action file (actions/posts.ts)
"use server";
// Good: authed action with metadata and typed errors
import { z } from "zod";
import { authedActionClient } from "@/lib/next-action-handler/safe-action";
import {
NotFoundError,
ForbiddenError,
} from "@/lib/next-action-handler/error/errors";
import { toDatabaseError } from "@/lib/next-action-handler/error/database-error";
import { db } from "@/lib/db";
export const deletePost = authedActionClient
.metadata({ actionName: "deletePost" })
.inputSchema(z.object({ postId: z.string().uuid() }))
.action(async ({ parsedInput, ctx }) => {
const post = await db.posts.findById(parsedInput.postId).catch((error) => {
throw toDatabaseError(error, "Failed to fetch post");
});
if (!post) throw new NotFoundError("Post not found");
if (post.authorId !== ctx.user.id) throw new ForbiddenError();
await db.posts.delete(post.id).catch((error) => {
throw toDatabaseError(error, "Failed to delete post");
});
return { deleted: true };
});Client component (components/DeletePostButton.tsx)
"use client";
// Good: check validationErrors before serverError
import { useState } from "react";
import { deletePost } from "@/actions/posts";
export function DeletePostButton({ postId }: { postId: string }) {
const [error, setError] = useState<string | null>(null);
async function handleClick() {
setError(null);
const result = await deletePost({ postId });
if (result.validationErrors) {
setError("Invalid input");
return;
}
if (result.serverError) {
setError(result.serverError.message);
return;
}
// result.data.deleted === true
}
return (
<>
<button onClick={handleClick}>Delete</button>
{error && <p className="text-red-500">{error}</p>}
</>
);
}---
2. Client error handling with useAction
"use client";
// Good: handle validationErrors and serverError
import { useAction } from "next-safe-action/hooks";
import { toast } from "sonner";
import { updateProfile } from "@/actions/profile";
export function ProfileForm() {
const { execute, isPending } = useAction(updateProfile, {
onSuccess: () => {
toast.success("Profile updated");
},
onError: ({ error }) => {
if (error.validationErrors) {
toast.error("Invalid input");
return;
}
if (error.serverError) {
toast.error(error.serverError.message);
}
},
});
return (
<button
onClick={() => execute({ displayName: "Alice" })}
disabled={isPending}
>
Save
</button>
);
}---
3. Output validation handling in safe-action.ts
// Good: keep PublicServerError shape and handle output validation errors
import "server-only";
import z from "zod";
import {
createSafeActionClient,
DEFAULT_SERVER_ERROR_MESSAGE,
} from "next-safe-action";
import { logActionError, logActionExecution } from "./log/logger";
import { normalizeError } from "./error/normalize-error";
import { InternalServerError } from "./error/errors";
import { requireUser } from "../auth-helpers";
const OUTPUT_VALIDATION_SERVER_ERROR_MESSAGE =
"Unexpected response. Please try again.";
function isActionOutputDataValidationError(error: unknown): error is Error {
return (
error instanceof Error &&
(error.name === "ActionOutputDataValidationError" ||
error.constructor?.name === "ActionOutputDataValidationError")
);
}
export const actionClient = createSafeActionClient({
defineMetadataSchema: () =>
z.object({
actionName: z.string(),
}),
handleServerError(error, ctx) {
if (isActionOutputDataValidationError(error)) {
const normalized = normalizeError(
new InternalServerError("Action output validation failed", error),
);
logActionError({
action: ctx.metadata.actionName,
error: normalized,
});
return {
code: normalized.code,
message: OUTPUT_VALIDATION_SERVER_ERROR_MESSAGE,
};
}
const normalized = normalizeError(error);
logActionError({
action: ctx.metadata.actionName,
error: normalized,
});
return {
code: normalized.code,
message: normalized.expose
? normalized.message
: DEFAULT_SERVER_ERROR_MESSAGE,
};
},
}).use(async ({ next, metadata }) => {
const startedAt = Date.now();
const result = await next();
if (!result.serverError) {
logActionExecution({
action: metadata.actionName,
durationMs: Date.now() - startedAt,
});
}
return result;
});
export const authedActionClient = actionClient.use(async ({ next }) => {
const user = await requireUser();
return next({ ctx: { user } });
});---
4. Unauthenticated action (public form)
"use server";
// Good: public action with metadata
import { z } from "zod";
import { actionClient } from "@/lib/next-action-handler/safe-action";
import { BadRequestError } from "@/lib/next-action-handler/error/errors";
import { db } from "@/lib/db";
export const subscribeToNewsletter = actionClient
.metadata({ actionName: "subscribeToNewsletter" })
.inputSchema(z.object({ email: z.string().email("Invalid email address") }))
.action(async ({ parsedInput }) => {
const existing = await db.subscribers.findByEmail(parsedInput.email);
if (existing) throw new BadRequestError("Email already subscribed");
await db.subscribers.create({ email: parsedInput.email });
return { subscribed: true };
});---
5. auth-helpers.ts with better-auth
// Good: requireUser used by authedActionClient
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { UnauthorizedError } from "@/lib/next-action-handler/error/errors";
export async function requireUser() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) throw new UnauthorizedError("You must be logged in");
return session.user;
}---
6. ValidationError with field-level errors
// Good: use ValidationError for custom server-side validation
import { ValidationError } from "@/lib/next-action-handler/error/errors";
throw new ValidationError("Passwords do not match", {
confirmPassword: ["Must match the password field"],
});On the client, ValidationError.fields is not forwarded via serverError. Schema failures still show up in result.validationErrors.