home / skills / gilbertopsantosjr / fullstacknextjs / gs-nextjs-server-actions

gs-nextjs-server-actions skill

/skills/gs-nextjs-server-actions

This skill guides implementing thin adapter server actions in Next.js by resolving use cases via DI and returning DTOs.

npx playbooks add skill gilbertopsantosjr/fullstacknextjs --skill gs-nextjs-server-actions

Review the files below or copy the command above to add this skill to your agents.

Files (4)
SKILL.md
6.3 KB
---
name: gs-nextjs-server-actions
description: "Guide for implementing thin adapter server actions using ZSA. Actions resolve Use Cases from DI Container and delegate business logic. Use when creating API endpoints in the Presentation layer."
---

# Next.js Server Actions (Thin Adapters)

Server actions are **thin adapters** in Clean Architecture. They:
- Resolve Use Cases from DI Container
- Pass input to Use Case's `execute()` method
- Return DTOs (not Entities)

## Thin Adapter Pattern (3-5 lines)

```typescript
// src/features/category/actions/create-category-action.ts
'use server'
import 'server-only'
import { authedProcedure } from '@/lib/zsa'
import { CreateCategorySchema } from '../schemas/category-schemas'
import { DIContainer, TOKENS } from '@/backend/infrastructure/di'
import type { CreateCategoryUseCase } from '@/backend/application/category/use-cases'

export const createCategoryAction = authedProcedure
  .createServerAction()
  .input(CreateCategorySchema)
  .handler(async ({ input, ctx }) => {
    const useCase = DIContainer.resolve<CreateCategoryUseCase>(TOKENS.CreateCategoryUseCase)
    return useCase.execute({ ...input, userId: ctx.user.id })
  })
```

## File Structure

```
src/features/<feature>/
├── actions/
│   ├── create-<entity>-action.ts
│   ├── update-<entity>-action.ts
│   ├── delete-<entity>-action.ts
│   ├── get-<entity>-action.ts
│   ├── list-<entity>s-action.ts
│   └── index.ts
└── schemas/
    └── <entity>-schemas.ts
```

## Required Directives

Every action file MUST include:
```typescript
'use server'           // First line - marks as server action
import 'server-only'   // Prevents client import
```

## Procedures

```typescript
// lib/procedures.ts
'use server'
import { createServerActionProcedure } from 'zsa'
import { auth } from '@/lib/auth'

export const authedProcedure = createServerActionProcedure()
  .handler(async () => {
    const session = await auth()
    if (!session?.user) throw new Error('Not authenticated')
    return { user: { id: session.user.id, email: session.user.email } }
  })

export const publicProcedure = createServerActionProcedure()
  .handler(async () => ({}))
```

## Action Patterns

### Query Action
```typescript
export const getCategoryAction = authedProcedure
  .createServerAction()
  .input(z.object({ id: z.string().ulid() }))
  .handler(async ({ input, ctx }) => {
    const useCase = DIContainer.resolve<GetCategoryUseCase>(TOKENS.GetCategoryUseCase)
    return useCase.execute({ id: input.id, userId: ctx.user.id })
  })
```

### List Action with Pagination
```typescript
export const listCategoriesAction = authedProcedure
  .createServerAction()
  .input(z.object({
    limit: z.coerce.number().min(1).max(100).optional(),
    cursor: z.string().optional(),
  }))
  .handler(async ({ input, ctx }) => {
    const useCase = DIContainer.resolve<ListCategoriesUseCase>(TOKENS.ListCategoriesUseCase)
    return useCase.execute({ ...input, userId: ctx.user.id })
  })
```

### Mutation with Revalidation
```typescript
export const updateCategoryAction = authedProcedure
  .createServerAction()
  .input(UpdateCategorySchema)
  .onComplete(async () => {
    revalidatePath('/categories')
  })
  .handler(async ({ input, ctx }) => {
    const useCase = DIContainer.resolve<UpdateCategoryUseCase>(TOKENS.UpdateCategoryUseCase)
    return useCase.execute({ ...input, userId: ctx.user.id })
  })
```

## Calling Actions

### From Client
```typescript
'use client'
import { useServerAction } from 'zsa-react'
import { createCategoryAction } from '@/features/category'

export function CreateForm() {
  const { isPending, execute, error, isSuccess } = useServerAction(createCategoryAction)

  const handleSubmit = async (formData: FormData) => {
    const [data, err] = await execute({ name: formData.get('name') as string })
    if (err) return console.error(err.message)
    // Success
  }

  return <form action={handleSubmit}>...</form>
}
```

### From Server
```typescript
const [data, err] = await createCategoryAction({ name: 'New Category' })
if (err) console.error(err.code, err.message)
```

## Error Handling

Use Cases throw domain exceptions, which propagate to the client:

```typescript
// Use Case throws
throw new CategoryNotFoundException(input.id)

// Client receives
const [data, err] = await execute(input)
if (err) {
  // err.message = "Category with id 01HX... not found"
  // err.code = "ERROR"
}
```

## Anti-Patterns

### ❌ Fat Actions (Business Logic in Action)
```typescript
// BAD - 50+ lines with business logic
export const createCategoryAction = authedProcedure
  .createServerAction()
  .input(CreateCategorySchema)
  .handler(async ({ input, ctx }) => {
    // Validation logic
    // Permission checks
    // Database operations
    // More business rules
  })
```

### ❌ Direct Repository Access
```typescript
// BAD - Bypasses Use Case
export const createCategoryAction = authedProcedure
  .handler(async ({ input, ctx }) => {
    const repo = DIContainer.resolve<ICategoryRepository>(TOKENS.CategoryRepository)
    const entity = Category.create({ ...input, userId: ctx.user.id })
    await repo.save(entity) // Direct repo access!
  })
```

### ❌ Direct Instantiation
```typescript
// BAD - Creates dependencies directly
export const createCategoryAction = authedProcedure
  .handler(async ({ input, ctx }) => {
    const repo = new DynamoDBCategoryRepository() // VIOLATION!
    const useCase = new CreateCategoryUseCase(repo) // VIOLATION!
    return useCase.execute(input)
  })
```

## Detection Commands

```bash
# Fat actions (direct DB access)
grep -rn "getDynamoDbTable\|getModel" src/features/*/actions/

# Direct instantiation
grep -rn "new.*UseCase(\|new.*Repository(" src/features/

# Action file sizes (should be <30 lines)
find src/features/*/actions -name "*.ts" ! -name "index.ts" -exec wc -l {} \;
```

## Best Practices

1. **3-5 lines in handler** - Resolve Use Case, execute, return
2. **DI Container** for all Use Case resolution
3. **Zod for input shape only** - Business rules in Entity
4. **Use `revalidatePath`/`revalidateTag`** after mutations
5. **Let Use Cases handle errors** - Domain exceptions propagate naturally

## References

- Feature Architecture: `skills/feature-architecture/SKILL.md`
- Zod Validation: `skills/zod-validation/SKILL.md`
- React Query: `skills/tanstack-react-query/SKILL.md`

Overview

This skill explains how to implement thin adapter Next.js Server Actions using ZSA. It focuses on resolving Use Cases from a DI container, delegating all business logic to application layer Use Cases, and returning DTOs. Use it to create consistent, testable API endpoints in the Presentation layer.

How this skill works

Each server action is a minimal adapter: it marks the file as server-only, validates input with Zod, resolves the appropriate Use Case from the DI container, calls the Use Case's execute() with composed input (e.g., adding userId from auth), and returns the resulting DTO. Authentication and common context are provided via predefined authedProcedure and publicProcedure helpers. Mutations can trigger Next.js revalidation hooks (revalidatePath/revalidateTag) in onComplete handlers.

When to use it

  • Implementing API endpoints in the Presentation layer that must stay thin and delegative
  • Exposing Use Case behavior to client and server callers without leaking domain logic
  • Creating consistent action files for CRUD operations (create, update, delete, get, list)
  • Adding server-only endpoints that require authenticated context or public access
  • Needing automatic propagation of domain errors to the caller

Best practices

  • Keep action handlers 3–5 lines: resolve Use Case, call execute(), return DTO
  • Always resolve Use Cases from the DI container; avoid direct instantiation
  • Restrict Zod to input shape validation; place business rules in Entities/Use Cases
  • Include 'use server' and import 'server-only' as the first lines of every action file
  • Use onComplete to call revalidatePath/revalidateTag after mutations when needed

Example use cases

  • createCategoryAction: validate input, resolve CreateCategoryUseCase, execute with ctx.user.id
  • getCategoryAction: accept id, resolve GetCategoryUseCase, return DTO for single entity
  • listCategoriesAction: accept limit/cursor, resolve ListCategoriesUseCase, support pagination
  • updateCategoryAction: run Use Case then revalidate listing path via onComplete
  • Calling actions from client via useServerAction or from server by invoking the action directly

FAQ

What should an action return: Entities or DTOs?

Actions should return DTOs. Entities and business rules belong to the domain and stay inside Use Cases.

Can I access repositories directly from an action?

No. Direct repository access bypasses Use Cases and breaks separation of concerns; always resolve Use Cases from the DI container.