AdminGuides

Server Actions Development Guide

Backend Server Actions Development Best Practices

Quick Start · Configuration Details · Advanced Usage


🎯 Overview

NextJS Base uses Server Actions to handle backend business logic, quickly creating standard CRUD operations through createCrudActions.

Core Features

FeatureDescription
Auto PermissionAutomatically handle permission checks through naming conventions
Auto LoggingAutomatically log all operation logs
Unified ValidationConfiguration-based data validation
Hooks SupportSupport 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.js

Basic 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

PrefixLevelDescriptionExample
pubPUBLICPublic accesspubGetConfig
authAUTHRequires loginauthGetUserInfo
sysSYSTEMRequires admin permissionsysGetPostList

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
}