Agent skill
server-actions-expert
Use this skill for creating safe Next.js server actions with next-safe-action and @kit/action-middleware. Includes authentication, organization context, RBAC permissions, admin actions, proper error handling, revalidation patterns, and form integration.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/server-actions-expert
SKILL.md
You are an expert in building secure, type-safe server actions using next-safe-action and the @kit/action-middleware package. You create actions that integrate seamlessly with Better Auth, organization multi-tenancy, and RBAC permissions.
CRITICAL: Always Check Permissions
Every server action MUST have appropriate permission checks. Never create an action without considering:
- Who can execute this action? Use the right action client
- What role/permission is required? Add middleware checks
- Does the user own this resource? Verify in service layer
Permission Check Examples
// BAD: No permission check - anyone authenticated can delete ANY post
export const deletePostAction = authenticatedActionClient
.inputSchema(DeletePostSchema)
.action(async ({ parsedInput }) => {
await db.delete(posts).where(eq(posts.id, parsedInput.postId));
});
// GOOD: Verify ownership in service
export const deletePostAction = authenticatedActionClient
.inputSchema(DeletePostSchema)
.action(async ({ parsedInput, ctx }) => {
await postService.delete({
postId: parsedInput.postId,
userId: ctx.user.id, // Service verifies ownership
});
});
// GOOD: Role-based permission for org actions
export const removeTeamMemberAction = authenticatedActionClient
.use(withMinRole('admin')) // Only admins can remove members
.inputSchema(RemoveMemberSchema)
.action(async ({ parsedInput, ctx }) => {
// ctx.organizationId verified by middleware
});
// GOOD: RBAC permission for specific features
export const updateBillingAction = authenticatedActionClient
.use(withFeaturePermission({ billing: ['update'] }))
.inputSchema(BillingSchema)
.action(async ({ parsedInput, ctx }) => {
// Only users with billing:update permission
});
Permission Checklist
Before finalizing any action, verify:
- Correct action client selected (authenticated/organization/admin)
- Role middleware added if action requires minimum role
- Permission middleware added for feature-specific actions
- Service layer verifies resource ownership with
userId/organizationId - Admin actions use
adminActionClient+withAdminPermission
Action Clients
Choose the right client based on authorization needs:
| Client | Context Provided | Use Case |
|---|---|---|
authenticatedActionClient |
ctx.user, ctx.session |
Any authenticated user action |
organizationActionClient |
ctx.user, ctx.organizationId, ctx.role |
Actions requiring org context |
adminActionClient |
ctx.user (admin verified) |
Admin-only actions |
adminPermissionActionClient |
Same as adminActionClient |
Admin actions with RBAC |
Middleware Functions
| Middleware | Purpose |
|---|---|
withMinRole(role) |
Require minimum role level in org |
withFeaturePermission(perms) |
Check RBAC permissions |
withAdminPermission(reqs) |
Check admin RBAC permissions |
File Structure
feature/
├── _lib/
│ ├── schemas/
│ │ └── feature.schema.ts # Zod validation schemas
│ ├── server/
│ │ └── feature-server-actions.ts # Server actions
│ └── services/
│ └── feature.service.ts # Business logic
Basic Authenticated Action
'use server';
import { revalidatePath } from 'next/cache';
import { authenticatedActionClient } from '@kit/action-middleware';
import { UpdateFeatureSchema } from '../schemas/feature.schema';
import { createFeatureService } from '../services/feature.service';
/**
* @name updateFeatureAction
* @description Updates feature for the authenticated user
*/
export const updateFeatureAction = authenticatedActionClient
.inputSchema(UpdateFeatureSchema)
.action(async ({ parsedInput: data, ctx }) => {
const service = createFeatureService();
const result = await service.update({
userId: ctx.user.id,
...data,
});
revalidatePath('/', 'layout');
return result;
});
Action Without Input
export const noInputAction = authenticatedActionClient.action(
async ({ ctx }) => {
// No schema needed when action takes no input
return { userId: ctx.user.id };
},
);
Organization-Scoped Action
'use server';
import { revalidatePath } from 'next/cache';
import { authenticatedActionClient } from '@kit/action-middleware';
import { requireActiveOrganizationId } from '@kit/better-auth/context';
import { InviteMemberSchema } from '../schemas/members.schema';
import { createInvitationsService } from '../services/invitations.service';
export const inviteMemberAction = authenticatedActionClient
.inputSchema(InviteMemberSchema)
.action(async ({ parsedInput: data, ctx }) => {
const organizationId = await requireActiveOrganizationId();
const service = createInvitationsService();
const result = await service.inviteMember({
userId: ctx.user.id,
organizationId,
invitation: data,
});
revalidatePath('/', 'layout');
return result;
});
Role-Protected Action
import { authenticatedActionClient, withMinRole } from '@kit/action-middleware';
export const ownerOnlyAction = authenticatedActionClient
.use(withMinRole('owner'))
.inputSchema(Schema)
.action(async ({ parsedInput, ctx }) => {
// Only org owners reach here
// ctx.organizationId and ctx.role available
});
Permission-Protected Action
import {
authenticatedActionClient,
withFeaturePermission,
} from '@kit/action-middleware';
export const billingAction = authenticatedActionClient
.use(withFeaturePermission({ billing: ['update'] }))
.inputSchema(Schema)
.action(async ({ ctx }) => {
// Only users with billing:update permission
});
Admin Action with Permission
'use server';
import { revalidatePath } from 'next/cache';
import { adminActionClient, withAdminPermission } from '@kit/action-middleware';
import { banUserSchema } from '../schemas';
import { createUserAdminService } from '../services/user-admin.service';
export const banUserAction = adminActionClient
.use(withAdminPermission({ user: ['ban'] }))
.inputSchema(banUserSchema)
.action(async ({ parsedInput, ctx }) => {
const service = createUserAdminService();
const result = await service.banUser({
adminId: ctx.user.id,
...parsedInput,
});
revalidatePath('/admin', 'layout');
return result;
});
Schema Patterns
Schemas go in _lib/schemas/ and are shared between client forms and server actions:
// feature.schema.ts
import * as z from 'zod';
export const UpdateFeatureSchema = z.object({
name: z
.string()
.min(2, 'feature.errors.nameMinLength')
.max(100, 'feature.errors.nameMaxLength'),
description: z.string().optional(),
});
export type UpdateFeatureInput = z.output<typeof UpdateFeatureSchema>;
Use i18n keys in error messages for localization support.
Consuming Actions in Forms
Use useAction from next-safe-action/hooks:
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@kit/ui/sonner';
import { updateFeatureAction } from '../server/feature-server-actions';
import { UpdateFeatureSchema } from '../schemas/feature.schema';
export function UpdateFeatureForm() {
const { executeAsync, status } = useAction(updateFeatureAction);
const isPending = status === 'executing';
const form = useForm({
resolver: zodResolver(UpdateFeatureSchema),
defaultValues: { name: '' },
});
return (
<form
onSubmit={form.handleSubmit((data) => {
toast.promise(
executeAsync(data).then((response) => {
if (response.serverError || response.validationErrors) {
throw new Error(response.serverError || 'An error occurred');
}
}),
{
loading: 'Saving...',
success: 'Saved successfully',
error: 'Failed to save',
},
);
})}
>
{/* Form fields */}
</form>
);
}
Revalidation Patterns
import { revalidatePath, revalidateTag } from 'next/cache';
// Revalidate entire layout (most common)
revalidatePath('/', 'layout');
// Revalidate specific page
revalidatePath('/settings', 'page');
// Revalidate admin area
revalidatePath('/admin', 'layout');
// Revalidate by tag (for fetch caching)
revalidateTag('user-data');
Error Handling
Errors are handled automatically by the middleware:
- Auth errors: Preserve message from Better Auth
- Production: Generic "Something went wrong" message
- Development: Actual error message shown
Standard error types:
"Unauthorized"- no session or permission denied"No active organization"- org required but not set"Unauthorized: {role} role required"- insufficient roleforbidden()fromnext/navigation- admin permission denied
For custom errors, throw with meaningful messages:
export const deleteAccountAction = authenticatedActionClient
.inputSchema(DeleteAccountSchema)
.action(async ({ parsedInput, ctx }) => {
const eligibility = await checkDeletionEligibility(ctx.user.id);
if (!eligibility.canDelete) {
throw new Error(
'Account cannot be deleted. Please check eligibility requirements.',
);
}
// Proceed with deletion
});
Context Properties
authenticatedActionClient
ctx: {
user: { id, email, name, role, ... };
session: { id, userId, expiresAt, ... };
}
organizationActionClient
ctx: {
user: { ... };
organizationId: string;
role: string; // User's role in org
}
adminActionClient
ctx: {
user: { ... }; // Verified admin user
}
Decision Tree
- User-only action →
authenticatedActionClient - Need org context → use
requireActiveOrganizationId()ororganizationActionClient - Role check needed →
authenticatedActionClient+withMinRole() - Permission check →
authenticatedActionClient+withFeaturePermission() - Admin area →
adminActionClient - Admin with RBAC →
adminActionClient+withAdminPermission()
Best Practices
- ALWAYS check permissions - Use middleware and verify ownership in services
- NEVER trust client input for authorization - Always use
ctx.user.id, neverparsedInput.userId - Always add
'use server'directive at top of file - Add JSDoc comment with
@nameand@descriptionfor each action - Use services for business logic, keep actions thin
- Always call
revalidatePathafter mutations - Return meaningful results, not just
{ success: true } - Pass
ctx.user.idandctx.organizationIdto services for ownership verification - Name files:
{feature}-server-actions.ts - Share schemas between client forms and server actions
- Use i18n keys in schema error messages
Security Anti-Patterns to Avoid
// NEVER: Trust user-provided IDs for authorization
export const badAction = authenticatedActionClient
.inputSchema(z.object({ userId: z.string() }))
.action(async ({ parsedInput }) => {
// Attacker can pass any userId!
await service.doSomething(parsedInput.userId);
});
// NEVER: Skip permission checks for "simple" actions
export const badDeleteAction = authenticatedActionClient
.action(async ({ parsedInput }) => {
// No ownership check = anyone can delete anything
await db.delete(items).where(eq(items.id, parsedInput.id));
});
// NEVER: Check permissions client-side only
// Client-side checks are for UX, server-side checks are for security
Didn't find tool you were looking for?