ESLint rules for safe Next.js server actions and Prisma bulk operations.
Battle-tested on a production multi-tenant B2B platform. These rules catch classes of bugs that TypeScript won't — silent result discards and unconditional bulk operations that wipe entire tables.
npm install --save-dev eslint-plugin-nextjs-server-actions// eslint.config.mjs
import nextjsServerActions from "eslint-plugin-nextjs-server-actions";
export default [
nextjsServerActions.configs.recommended,
// ... your other configs
];Or enable rules individually:
import nextjsServerActions from "eslint-plugin-nextjs-server-actions";
export default [
{
plugins: { "nextjs-server-actions": nextjsServerActions },
rules: {
"nextjs-server-actions/no-dangerous-prisma": "error",
"nextjs-server-actions/no-unchecked-action-result": "error",
"nextjs-server-actions/enforce-try-action": "warn",
},
},
];Prevents unconditional Prisma bulk operations that silently wipe entire database tables.
Blocked patterns:
// ❌ no WHERE clause — deletes ALL rows
await prisma.user.deleteMany();
await prisma.order.deleteMany({});
await prisma.product.deleteMany({ where: {} });
// ❌ same for updateMany
await prisma.user.updateMany();
// ❌ dangerous raw SQL
await prisma.$executeRaw`DROP TABLE "User"`;
await prisma.$executeRawUnsafe("TRUNCATE orders");Correct patterns:
// ✅ always provide a real WHERE condition
await prisma.user.deleteMany({ where: { tenantId: companyId } });
await prisma.session.deleteMany({ where: { expiresAt: { lt: new Date() } } });Disable for seed scripts / test fixtures only:
// eslint-disable-next-line nextjs-server-actions/no-dangerous-prisma
await prisma.user.deleteMany();Next.js server actions wrapped in a try/catch helper return { error?: string } instead of throwing. Discarding the return value silently swallows all errors.
Blocked patterns:
import { createOrder } from "@/app/actions/order";
// ❌ result discarded — errors are silently lost
await createOrder(payload);
void createOrder(payload);Correct patterns:
// ✅ capture and handle the result
const result = await createOrder(payload);
if (result?.error) {
toast(result.error, "error");
return;
}Scope: Only applies to functions imported from action modules (@/app/actions/**, ../actions/, ./actions/). Files with "use server" directive are excluded (they are the action definitions, not callers).
Requires all exported async functions in "use server" files to be wrapped in a tryAction() call. Prevents unhandled exceptions from leaking raw server errors to the client.
Example tryAction helper:
// lib/try-action.ts
export async function tryAction<T>(
fn: () => Promise<T>
): Promise<{ data?: T; error?: string }> {
try {
return { data: await fn() };
} catch (err) {
console.error(err);
return { error: err instanceof Error ? err.message : "Unexpected error" };
}
}Blocked patterns:
"use server";
// ❌ unhandled exception exposed to client
export async function deleteProduct(id: string) {
await prisma.product.delete({ where: { id } });
}Correct patterns:
"use server";
// ✅ errors caught and normalized
export async function deleteProduct(id: string) {
return tryAction(async () => {
await prisma.product.delete({ where: { id } });
});
}Configurable wrapper name:
// eslint.config.mjs
{
rules: {
"nextjs-server-actions/enforce-try-action": ["warn", { wrapperName: "withAction" }],
},
}In a multi-tenant B2B platform, the most dangerous bugs are the ones that don't throw — they just silently do the wrong thing:
- A
deleteMany()without awhereclause empties the whole table; Prisma's TypeScript types don't prevent it. - A
await someAction()without capturing the result means users never see error messages; the action failed, but the UI proceeds as if it succeeded. - An unguarded
asyncserver action lets stack traces and database errors leak to client responses.
These rules encode lessons from production incidents into compile-time checks.
MIT