.claude/skills/server-actions/SKILL.md
Next.js Server Actions for mutations in this application. Covers entity actions, user actions, team actions, and best practices. Use this skill when implementing mutations from Client Components.
npx skillsauth add NextSpark-js/nextspark server-actionsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Patterns for using Next.js Server Actions to perform mutations from Client Components.
core/lib/actions/
├── index.ts # Re-exports all actions
├── types.ts # Shared types (EntityActionResult, etc.)
├── entity.actions.ts # Generic CRUD for any registered entity
├── user.actions.ts # User profile management
└── team.actions.ts # Team and member management
'use client')// From Client Components
'use client'
import {
// Entity actions
createEntity,
updateEntity,
deleteEntity,
getEntity,
listEntities,
deleteEntities,
entityExists,
countEntities,
// User actions
updateProfile,
updateAvatar,
deleteAccount,
// Team actions
updateTeam,
inviteMember,
removeMember,
updateMemberRole,
} from '@nextsparkjs/core/actions'
All actions follow the same security pattern:
userId comes from getTypedSession()teamId comes from httpOnly cookie activeTeamIdcheckPermission()Never pass userId/teamId as parameters:
// WRONG - Never trust client data for auth
export async function updateProfile(userId: string, data: ProfileData) { ... }
// CORRECT - Get auth from server context
export async function updateProfile(data: ProfileData) {
const session = await getTypedSession(await headers())
if (!session?.user?.id) {
return { success: false, error: 'Authentication required' }
}
const userId = session.user.id
// ...
}
Create a new entity record.
const result = await createEntity('products', {
name: 'New Product',
price: 99.99,
status: 'draft',
})
if (result.success) {
console.log('Created:', result.data)
} else {
console.error('Error:', result.error)
}
Update an existing entity.
const result = await updateEntity('products', productId, {
status: 'published',
})
// With custom revalidation
await updateEntity('products', productId, data, {
revalidatePaths: ['/dashboard/overview'],
revalidateTags: ['product-stats'],
})
Delete an entity by ID.
const result = await deleteEntity('products', productId)
// With redirect after delete
await deleteEntity('products', productId, {
redirectTo: '/dashboard/products',
})
Delete multiple entities at once.
const result = await deleteEntities('products', ['id1', 'id2', 'id3'])
if (result.success) {
console.log(`Deleted ${result.data.deletedCount} products`)
}
For reads from Client Components (prefer Server Components when possible).
// Get single entity
const result = await getEntity('products', productId)
// List with pagination and filters
const result = await listEntities('products', {
limit: 20,
offset: 0,
where: { status: 'active' },
orderBy: 'createdAt',
orderDir: 'desc',
})
// Check if entity exists
const result = await entityExists('products', productId)
// Count with filter
const result = await countEntities('products', { status: 'active' })
Update the current user's profile.
const result = await updateProfile({
firstName: 'John',
lastName: 'Doe',
timezone: 'America/New_York',
language: 'en',
})
// Allowed fields: firstName, lastName, name, country, timezone, language
Update the user's avatar (URL, not file upload).
// After uploading file to storage and getting URL
const formData = new FormData()
formData.append('avatar', 'https://example.com/uploaded-avatar.jpg')
const result = await updateAvatar(formData)
Delete the current user's account.
// Check conditions first!
const result = await deleteAccount()
// Will fail if user owns teams
// Error: "Cannot delete account while owning teams. Transfer ownership first."
Update team information (requires owner or admin role).
const result = await updateTeam(teamId, {
name: 'New Team Name',
description: 'Updated description',
slug: 'new-slug',
})
// Will check slug availability if changing
Add a user to the team (requires owner or admin role).
const result = await inviteMember(
teamId,
'[email protected]', // User must already exist
'member' // 'member' | 'admin' | 'viewer'
)
if (result.success) {
console.log('Invited:', result.data.memberId)
}
Remove a member from the team.
const result = await removeMember(teamId, userId)
// Cannot remove:
// - Team owner (must transfer ownership first)
// - Other admins (if requestor is admin, not owner)
Change a member's role.
const result = await updateMemberRole(teamId, userId, 'admin')
// Restrictions:
// - Cannot set role to 'owner' (use transferOwnership)
// - Only owner can promote to admin
// - Only owner can demote admins
All actions return a discriminated union:
type EntityActionResult<T> =
| { success: true; data: T }
| { success: false; error: string }
type EntityActionVoidResult =
| { success: true }
| { success: false; error: string }
Usage with type guards:
const result = await createEntity('products', data)
if (result.success) {
// TypeScript knows result.data exists
console.log(result.data.id)
} else {
// TypeScript knows result.error exists
toast.error(result.error)
}
Entity actions automatically revalidate:
/dashboard/{entitySlug}/dashboard/{entitySlug} and /dashboard/{entitySlug}/{id}/dashboard/{entitySlug}await createEntity('products', data, {
revalidatePaths: ['/dashboard/overview', '/public/catalog'],
revalidateTags: ['product-count', 'catalog'],
})
await deleteEntity('products', id, {
redirectTo: '/dashboard/products',
})
// Note: redirect() throws NEXT_REDIRECT - action won't return data
'use client'
function ProductForm() {
const form = useForm()
async function onSubmit(data) {
const result = await createEntity('products', data)
if (result.success) {
router.push(`/products/${result.data.id}`)
} else {
form.setError('root', { message: result.error })
}
}
return <form onSubmit={form.handleSubmit(onSubmit)}>...</form>
}
'use client'
function ProfileForm() {
async function handleAction(formData: FormData) {
const result = await updateProfile({
firstName: formData.get('firstName') as string,
lastName: formData.get('lastName') as string,
})
// Handle result
}
return (
<form action={handleAction}>
<input name="firstName" />
<input name="lastName" />
<button type="submit">Save</button>
</form>
)
}
// core/lib/actions/[domain].actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { headers } from 'next/headers'
import { getTypedSession } from '../auth'
import type { EntityActionResult, EntityActionVoidResult } from './types'
export async function myAction(data: MyInput): Promise<EntityActionResult<MyOutput>> {
try {
// 1. Get auth context
const headersList = await headers()
const session = await getTypedSession(headersList)
if (!session?.user?.id) {
return { success: false, error: 'Authentication required' }
}
const userId = session.user.id
// 2. Validate input
if (!data.requiredField) {
return { success: false, error: 'Required field is missing' }
}
// 3. Check permissions (if needed)
// const hasPermission = await checkPermission(userId, teamId, 'resource.action')
// 4. Execute business logic via Service
const result = await MyService.doSomething(userId, data)
// 5. Revalidate caches
revalidatePath('/dashboard/relevant-path')
return { success: true, data: result }
} catch (error) {
console.error('[myAction] Error:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
'use server' at toprevalidatePath called for affected routesEntityActionResult or EntityActionVoidResult)index.ts// tests/jest/lib/actions/my.actions.test.ts
import { myAction } from '@/core/lib/actions/my.actions'
// Mock dependencies
jest.mock('@/core/lib/auth', () => ({
getTypedSession: jest.fn(),
}))
jest.mock('next/cache', () => ({
revalidatePath: jest.fn(),
}))
jest.mock('next/headers', () => ({
headers: jest.fn().mockReturnValue(new Headers()),
}))
describe('myAction', () => {
beforeEach(() => {
jest.clearAllMocks()
// Setup authenticated user
const { getTypedSession } = require('@/core/lib/auth')
getTypedSession.mockResolvedValue({
user: { id: 'user-123' },
})
})
it('succeeds with valid data', async () => {
const result = await myAction({ name: 'Test' })
expect(result.success).toBe(true)
})
it('fails when not authenticated', async () => {
const { getTypedSession } = require('@/core/lib/auth')
getTypedSession.mockResolvedValue(null)
const result = await myAction({ name: 'Test' })
expect(result.success).toBe(false)
expect(result.error).toBe('Authentication required')
})
})
deleteEntities instead of multiple deleteEntityservice-layer - Business logic implementationtanstack-query - Optimistic updates and cachingreact-patterns - Form and component patternszod-validation - Input validation schemasdevelopment
Zod validation patterns for this Next.js application. Covers schema definition, API validation, form integration, error formatting, and type inference. Use this skill when implementing validation for APIs, forms, or entity schemas.
development
Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
testing
Test coverage metrics and registry system for this Next.js application. Covers FEATURE_REGISTRY, FLOW_REGISTRY, TAGS_REGISTRY, and coverage metrics interpretation. Use this skill when evaluating test coverage, identifying gaps, or planning testing priorities.
development
TanStack Query (React Query) patterns for data fetching in this Next.js application. Covers useQuery, useMutation, optimistic updates, cache invalidation, and anti-patterns. Use this skill when implementing data fetching or state management with server data.