server-actions
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.
Files1
View on GitHubSKILL.md.claude/skills/server-actions/SKILL.md
GitHubServer Actions Skill
Patterns for using Next.js Server Actions to perform mutations from Client Components.
Architecture Overview
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
When to Use This Skill
- Calling mutations from Client Components (
'use client') - Form submissions without JavaScript
- Automatic cache revalidation after mutations
- Type-safe end-to-end mutations
Import Pattern
// 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'
Security Model
All actions follow the same security pattern:
- Auth is obtained from session/cookies (NEVER from client parameters)
userIdcomes fromgetTypedSession()teamIdcomes fromhttpOnlycookieactiveTeamId- Permissions are checked via
checkPermission()
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
// ...
}
Entity Actions
createEntity
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)
}
updateEntity
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'],
})
deleteEntity
Delete an entity by ID.
const result = await deleteEntity('products', productId)
// With redirect after delete
await deleteEntity('products', productId, {
redirectTo: '/dashboard/products',
})
deleteEntities (Batch)
Delete multiple entities at once. At most 100 ids per call (MAX_BATCH_DELETE_IDS); a longer list is refused.
const result = await deleteEntities('products', ['id1', 'id2', 'id3'])
if (result.success) {
console.log(`Deleted ${result.data.deletedCount} products`)
}
getEntity / listEntities
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',
})
entityExists / countEntities
// Check if entity exists
const result = await entityExists('products', productId)
// Count with filter
const result = await countEntities('products', { status: 'active' })
User Actions
updateProfile
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
updateAvatar
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)
deleteAccount
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."
Team Actions
updateTeam
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
inviteMember
Send an invitation to join the team (requires a role with the team.members.invite permission). It follows
POST /api/v1/teams/:teamId/members: a pending invitation and an email, through TeamInvitationService, with the same
role-hierarchy, duplicate and rate-limit rules. Nobody is added until the invitee accepts.
const result = await inviteMember(
teamId,
'user@example.com', // May not have an account yet
'member' // A role below the inviter's own
)
if (result.success) {
console.log('Invitation sent:', result.data.invitationId, result.data.status) // 'pending'
}
removeMember
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)
updateMemberRole
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
Result Types
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)
}
Revalidation Patterns
Default Revalidation
Entity actions automatically revalidate:
- Create:
/dashboard/{entitySlug} - Update:
/dashboard/{entitySlug}and/dashboard/{entitySlug}/{id} - Delete:
/dashboard/{entitySlug}
Custom Revalidation
await createEntity('products', data, {
revalidatePaths: ['/dashboard/overview', '/public/catalog'],
revalidateTags: ['product-count', 'catalog'],
})
Redirect After Mutation
await deleteEntity('products', id, {
redirectTo: '/dashboard/products',
})
// Note: redirect() throws NEXT_REDIRECT - action won't return data
Form Integration
With React Hook Form
'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>
}
With Native Forms
'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>
)
}
Creating New Server Actions
Template
// 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',
}
}
}
Checklist for New Actions
- File marked with
'use server'at top - Auth obtained from session (not parameters)
- Input validation before service calls
- Permission checks where needed
- Business logic delegated to Services
-
revalidatePathcalled for affected routes - Error handling with try/catch
- Proper return type (
EntityActionResultorEntityActionVoidResult) - Export from
index.ts
Testing Server Actions
// tests/jest/lib/actions/my.actions.test.ts
import { myAction } from '@nextsparkjs/core/lib/actions/my.actions'
// Mock dependencies
jest.mock('@nextsparkjs/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('@nextsparkjs/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('@nextsparkjs/core/lib/auth')
getTypedSession.mockResolvedValue(null)
const result = await myAction({ name: 'Test' })
expect(result.success).toBe(false)
expect(result.error).toBe('Authentication required')
})
})
Performance Considerations
- Prefer Server Components for reads - Server Actions are for mutations
- Batch operations when possible - Use
deleteEntitiesinstead of multipledeleteEntity - Minimal revalidation - Only revalidate affected paths
- Early validation - Fail fast before expensive operations
Related Skills
service-layer- Business logic implementationtanstack-query- Optimistic updates and cachingreact-patterns- Form and component patternszod-validation- Input validation schemas