AdminGuides
Server Actions Development Guide
Backend Server Actions Development Best Practices
🎯 Overview
NextJS Base uses Server Actions to handle backend business logic, quickly creating standard CRUD operations through createCrudActions.
Core Features
| Feature | Description |
|---|---|
| Auto Permission | Automatically handle permission checks through naming conventions |
| Auto Logging | Automatically log all operation logs |
| Unified Validation | Configuration-based data validation |
| Hooks Support | Support before/after hooks |
🚀 Quick Start
Create Using Template
# Create Action file
cp templates/crud/action.template.js app/(admin)/actions/your-module/crud-action.your-resource.js
# Replace variables
sed -i '' 's/{RESOURCE_NAME}/yourResource/g' crud-action.your-resource.js
sed -i '' 's/{RESOURCE_LABEL}/YourResource/g' crud-action.your-resource.js
sed -i '' 's/{MODEL_NAME}/yourResource/g' crud-action.your-resource.jsBasic Example
'use server'
import { createCrudActions } from '@/lib/core/crud-helper'
// Resource Configuration
const postConfig = {
modelName: 'post',
primaryKey: 'id',
softDelete: true,
fields: {
creatable: ['title', 'content', 'status'],
updatable: ['title', 'content', 'status'],
searchable: ['title', 'content'],
},
query: {
defaultSort: { createdAt: 'desc' },
defaultPageSize: 20,
},
validation: {
title: { required: true, maxLength: 200 },
content: { required: true },
status: { enum: ['draft', 'published'] },
},
}
// Export CRUD Actions
export const {
getList: getPostListAction,
getDetail: getPostDetailAction,
create: createPostAction,
update: updatePostAction,
delete: deletePostAction,
} = createCrudActions(postConfig)📋 Configuration Details
Basic Configuration
const resourceConfig = {
// Model name (corresponds to Prisma model)
modelName: 'post',
// Primary key field
primaryKey: 'id',
// Whether to enable soft delete
softDelete: true,
// Whether admin permission is required (default false, uses RBAC permissions)
requireAdmin: false,
}Field Configuration (fields)
fields: {
// Fields allowed during creation
creatable: ['title', 'content', 'categoryId', 'tags', 'status'],
// Fields allowed during update
updatable: ['title', 'content', 'categoryId', 'tags', 'status'],
// Searchable fields
searchable: ['title', 'content'],
}Query Configuration (query)
query: {
// Default sort
defaultSort: { createdAt: 'desc' },
// Default page size
defaultPageSize: 20,
// Default filter conditions
defaultFilter: { enable: true },
// Related queries (Prisma include)
include: {
author: true,
category: true,
},
}Validation Configuration (validation)
validation: {
// Required + length limit
title: {
required: true,
minLength: 2,
maxLength: 200,
message: 'Title required, 2-200 characters',
},
// Enum validation
status: {
enum: ['draft', 'published', 'archived'],
message: 'Invalid status value',
},
// Regex validation
slug: {
pattern: /^[a-z0-9-]+$/,
message: 'URL alias can only contain lowercase letters, numbers and hyphens',
},
// Number range
sort: {
min: 0,
max: 9999,
message: 'Sort value must be between 0-9999',
},
// Uniqueness validation
email: {
required: true,
unique: true,
message: 'Email already exists',
},
// Custom validator
password: {
required: true,
validator: async (value, data, recordId) => {
if (value.length < 8) {
throw new Error('Password at least 8 characters')
}
if (!/[A-Z]/.test(value)) {
throw new Error('Password must contain uppercase letters')
}
},
},
}Hooks Configuration
hooks: {
// Before create
beforeCreate: async (data, ctx) => {
// Can modify data
data.authorId = ctx.userId
data.slug = generateSlug(data.title)
return data
},
// After create
afterCreate: async (record, ctx) => {
// Send notifications, clear cache, etc.
await sendNotification('New article published', record.title)
},
// Before update
beforeUpdate: async (id, data, ctx) => {
// Check permissions
const post = await getPost(id)
if (post.authorId !== ctx.userId && !ctx.isAdmin) {
throw new Error('No permission to modify others\' articles')
}
return data
},
// After update
afterUpdate: async (record, ctx) => {
await clearCache(`post:${record.id}`)
},
// Before delete
beforeDelete: async (id, ctx) => {
const post = await getPost(id)
if (post.status === 'published') {
throw new Error('Published articles cannot be deleted')
}
return true // Return false to prevent deletion
},
// After delete
afterDelete: async (id, ctx) => {
await clearCache(`post:${id}`)
},
// Before batch delete
beforeBatchDelete: async (ids, ctx) => {
// Check if there are published articles
const posts = await getPosts(ids)
const published = posts.filter(p => p.status === 'published')
if (published.length > 0) {
throw new Error(`${published.length} published articles cannot be deleted`)
}
return true
},
}Data Transformation (transforms)
transforms: {
// Transform before write
input: (data) => {
// Process input data
if (data.code) {
data.code = data.code.toUpperCase().trim()
}
return data
},
// Transform after read
output: (record) => {
// Process output data
if (record.price) {
record.priceDisplay = `$${record.price.toFixed(2)}`
}
return record
},
}🎨 Advanced Usage
Custom Action
import { wrapAction } from '@/lib/core/action-wrapper'
import { prisma } from '@/lib/database/prisma'
// Export standard CRUD
export const { getList, create, update, delete: del } = createCrudActions(config)
// Custom Action - Publish Post
export const publishPostAction = wrapAction(
'sysPublishPost', // sys prefix requires admin permission
async (params, ctx) => {
const { id } = params
const post = await prisma.post.findUnique({ where: { id } })
if (!post) {
throw new Error('Post not found')
}
if (post.status === 'published') {
throw new Error('Post already published')
}
const updated = await prisma.post.update({
where: { id },
data: {
status: 'published',
publishedAt: new Date(),
},
})
return { success: true, data: updated }
}
)
// Public Action - Get Published Posts
export const getPublishedPostsAction = wrapAction(
'pubGetPublishedPosts', // pub prefix no login required
async (params) => {
const posts = await prisma.post.findMany({
where: { status: 'published', enable: true },
orderBy: { publishedAt: 'desc' },
take: params.limit || 10,
})
return { success: true, data: posts }
}
)
// Action requiring login
export const likePostAction = wrapAction(
'authLikePost', // auth prefix requires login
async (params, ctx) => {
const { postId } = params
await prisma.postLike.create({
data: {
postId,
userId: ctx.userId,
},
})
return { success: true }
}
)Extend CRUD Actions
import { createCrudActions, extendCrudActions } from '@/lib/core/crud-helper'
const baseActions = createCrudActions(config)
// Extend additional Actions
export const actions = extendCrudActions(baseActions, {
// Toggle status
toggleStatus: wrapAction('sysTogglePostStatus', async ({ id }) => {
const post = await prisma.post.findUnique({ where: { id } })
return await prisma.post.update({
where: { id },
data: { enable: !post.enable },
})
}),
// Batch publish
batchPublish: wrapAction('sysBatchPublishPost', async ({ ids }) => {
return await prisma.post.updateMany({
where: { id: { in: ids } },
data: { status: 'published', publishedAt: new Date() },
})
}),
})Complex Queries
// Use selects for join queries
const config = {
modelName: 'post',
query: {
// Use selects for join queries (supports array field relations)
selects: {
author: {
localKey: 'authorId',
foreignKey: 'id',
foreignDB: 'user',
type: 'object', // Single relation
fields: ['id', 'name', 'avatar'],
},
category: {
localKey: 'categoryId',
foreignKey: 'id',
foreignDB: 'category',
type: 'object',
fields: ['id', 'name'],
},
},
},
}Read-Only Actions
import { createReadOnlyActions } from '@/lib/core/crud-helper'
// Only create read-related Actions
export const {
getList: getLogListAction,
getDetail: getLogDetailAction,
} = createReadOnlyActions({
modelName: 'actionLog',
query: {
defaultSort: { createdAt: 'desc' },
defaultPageSize: 50,
},
})📐 Naming Conventions
Action Naming
| Prefix | Level | Description | Example |
|---|---|---|---|
pub | PUBLIC | Public access | pubGetConfig |
auth | AUTH | Requires login | authGetUserInfo |
sys | SYSTEM | Requires admin permission | sysGetPostList |
Complete Naming Format
{prefix}{Action}{Resource}Action
Examples:
- sysGetPostListAction // Get post list
- sysGetPostDetailAction // Get post detail
- sysCreatePostAction // Create post
- sysUpdatePostAction // Update post
- sysDeletePostAction // Delete post
- sysBatchDeletePostAction // Batch delete posts
- sysPublishPostAction // Publish post (custom)File Naming
app/(admin)/actions/
├── rbac/
│ ├── crud-action.role.js
│ ├── crud-action.permission.js
│ └── crud-action.menu.js
├── content/
│ ├── crud-action.post.js
│ └── crud-action.category.js
└── system/
├── crud-action.asset.js
└── crud-action.action-log.js📊 Response Format
Success Response
{
success: true,
data: {
// Single record
id: 'xxx',
name: 'Test',
// ...
}
}
// Or list data
{
success: true,
data: {
list: [...],
total: 100,
page: 1,
pageSize: 20,
}
}Error Response
{
success: false,
error: 'Error message',
code: 'ERROR_CODE', // Optional
}📚 Related Documentation
wrapAction API
Action wrapper authentication and error handling
createCrudActions API
CRUD factory function configuration and extension
BaseDAO API
Low-level capabilities of data access objects
Action Template
CRUD Action template file ready to use
SmartForm Guide
Best practices for form interaction integration
RBAC Configuration Guide
Control Server Actions access with permissions