# Action Middleware

> Configure authentication and authorization middleware for server actions in your Next.js Drizzle SaaS application.

*Canonical: https://makerkit.dev/docs/nextjs-drizzle/development-guide/action-middleware*

---

Middleware eliminates boilerplate from server actions. Instead of manually checking authentication in every action, you chain pre-built middleware that handles auth, permissions, and context injection. This keeps your action logic focused on business requirements.

## Action Clients

Action clients are pre-configured with middleware chains. Choose the right client based on what your action requires.

### authenticatedActionClient

The most common choice. This client verifies the user has a valid session before your action runs:

```typescript
import { authenticatedActionClient } from '@kit/action-middleware';

export const myAction = authenticatedActionClient
  .inputSchema(mySchema)
  .action(async ({ parsedInput, ctx }) => {
    const { user } = ctx; // Authenticated user guaranteed

    // Action logic
  });
```

The middleware automatically:
- Verifies the session token is valid
- Throws an error if the user isn't authenticated
- Injects the authenticated user into `ctx`

### organizationActionClient

Use when your action requires organization context. This client extends `authenticatedActionClient` and adds organization membership verification:

```typescript
import { organizationActionClient } from '@kit/action-middleware';

export const createProjectAction = organizationActionClient
  .inputSchema(createProjectSchema)
  .action(async ({ parsedInput, ctx }) => {
    const { user, organizationId, role } = ctx;
    // organizationId and role are guaranteed to exist
    // User is verified as a member of the organization

    return db.insert(project).values({
      ...parsedInput,
      organizationId,
    }).returning();
  });
```

The middleware:
- Verifies the user session
- Gets the active organization from the session
- Confirms the user is a member of that organization
- Injects `organizationId` and `role` into the context

### adminActionClient

For super-admin operations like banning users or accessing any account's data. This client adds an admin role check on top of authentication:

```typescript
import { adminActionClient } from '@kit/action-middleware';

export const myAdminAction = adminActionClient
  .inputSchema(mySchema)
  .action(async ({ parsedInput, ctx }) => {
    const { user } = ctx; // Verified admin user

    // Admin-only logic
  });
```

The middleware:
- Verifies the user session
- Confirms the user has an admin role
- Returns 403 Forbidden if either check fails

## Authorization Middleware

For granular permission checks, chain these middleware functions onto action clients.

### withMinRole

Checks if the user has at least the specified role level. Role hierarchy is: `member` < `admin` < `owner`.

```typescript
import { authenticatedActionClient, withMinRole } from '@kit/action-middleware';

// Only admins and owners can reach this action
export const updateOrgSettingsAction = authenticatedActionClient
  .use(withMinRole('admin'))
  .inputSchema(updateOrgSettingsSchema)
  .action(async ({ parsedInput, ctx }) => {
    // ctx.role is guaranteed to be 'admin' or 'owner'
    // ctx.organizationId is available
  });

// Only owners can delete the organization
export const deleteOrgAction = authenticatedActionClient
  .use(withMinRole('owner'))
  .inputSchema(deleteOrgSchema)
  .action(async ({ parsedInput, ctx }) => {
    // Only owners reach here
  });
```

### withFeaturePermission

Checks if the user's role has specific feature permissions. Permissions are defined in your RBAC configuration.

```typescript
import { authenticatedActionClient, withFeaturePermission } from '@kit/action-middleware';

// Check single permission
export const createFeedbackAction = authenticatedActionClient
  .use(withFeaturePermission({ feedback: ['create'] }))
  .inputSchema(createFeedbackSchema)
  .action(async ({ parsedInput: data, ctx }) => {
    // Only roles with 'feedback:create' permission can reach here
  });

// Check multiple permissions
export const manageBoardAction = authenticatedActionClient
  .use(withFeaturePermission({
    board: ['update', 'delete'],
  }))
  .inputSchema(manageBoardSchema)
  .action(async ({ parsedInput, ctx }) => {
    // Requires both board:update AND board:delete permissions
  });
```

### withAdminPermission

For admin panel actions, checks permissions defined in the admin RBAC configuration:

```typescript
import { adminActionClient, withAdminPermission } from '@kit/action-middleware';

// Ban user requires 'user:ban' admin permission
export const banUserAction = adminActionClient
  .use(withAdminPermission({ user: ['ban'] }))
  .inputSchema(banUserSchema)
  .action(async ({ parsedInput, ctx }) => {
    // Only admins with 'user:ban' permission
    await auth.api.banUser({
      body: {
        userId: parsedInput.userId,
        banReason: parsedInput.reason,
      },
    });
  });

// Multiple admin permissions
export const manageUserAction = adminActionClient
  .use(withAdminPermission({
    user: ['get', 'ban'],
    session: ['revoke'],
  }))
  .inputSchema(manageUserSchema)
  .action(async ({ parsedInput, ctx }) => {
    // Requires all specified permissions
  });
```

## Usage Examples

### Basic Authenticated Action

Most actions follow this pattern: validate input with a Zod schema and perform the operation:

```typescript
'use server';

import { z } from 'zod';

import { authenticatedActionClient } from '@kit/action-middleware';
import { db, user } from '@kit/database';
import { eq } from 'drizzle-orm';

const updateProfileSchema = z.object({
  name: z.string().min(1),
});

export const updateProfileAction = authenticatedActionClient
  .inputSchema(updateProfileSchema)
  .action(async ({ parsedInput, ctx }) => {
    const [updated] = await db
      .update(user)
      .set({ name: parsedInput.name })
      .where(eq(user.id, ctx.user.id))
      .returning();

    return { success: true, user: updated };
  });
```

### Organization-Scoped Action

For multi-tenant operations, ensure all database queries include the organization filter:

```typescript
'use server';

import { revalidatePath } from 'next/cache';

import { authenticatedActionClient, withFeaturePermission } from '@kit/action-middleware';
import { getActiveOrganizationId } from '@kit/better-auth/context';
import { db, project } from '@kit/database';
import { and, eq } from 'drizzle-orm';

export const deleteProjectAction = authenticatedActionClient
  .use(withFeaturePermission({ project: ['delete'] }))
  .inputSchema(z.object({ projectId: z.string() }))
  .action(async ({ parsedInput, ctx }) => {
    const organizationId = await getActiveOrganizationId();

    if (!organizationId) {
      throw new Error('No active organization');
    }

    // Always filter by both ID and organizationId for security
    await db
      .delete(project)
      .where(
        and(
          eq(project.id, parsedInput.projectId),
          eq(project.organizationId, organizationId)
        )
      );

    revalidatePath('/projects', 'layout');
  });
```

### Admin Action

Admin actions bypass organization context entirely and operate at the platform level:

```typescript
'use server';

import { revalidatePath } from 'next/cache';

import { adminActionClient, withAdminPermission } from '@kit/action-middleware';
import { z } from 'zod';

const impersonateSchema = z.object({
  userId: z.string(),
});

export const impersonateUserAction = adminActionClient
  .use(withAdminPermission({ user: ['impersonate'] }))
  .inputSchema(impersonateSchema)
  .action(async ({ parsedInput, ctx }) => {
    // Only super admins with impersonation permission reach here

    await auth.api.impersonateUser({
      body: { userId: parsedInput.userId },
    });

    revalidatePath('/admin', 'layout');
  });
```

## Error Handling

When middleware rejects a request (invalid session, missing permission), the action throws. On the client, check the result for errors:

```typescript
'use client';

import { useAction } from 'next-safe-action/hooks';
import { myAction } from './actions';

function MyComponent() {
  const { execute, status, result } = useAction(myAction);

  const handleSubmit = async (data) => {
    const result = await execute(data);

    if (result?.serverError) {
      // Handle auth or permission errors
      console.error(result.serverError);
    }

    if (result?.validationErrors) {
      // Handle validation errors
      console.error(result.validationErrors);
    }
  };
}
```

## Context Properties

Middleware enriches the `ctx` object passed to your action. The exact properties depend on which middleware ran:

| Client | Context Properties |
|--------|-------------------|
| `authenticatedActionClient` | `user` |
| `organizationActionClient` | `user`, `organizationId`, `role` |
| `adminActionClient` | `user` |
| `withMinRole` | adds `organizationId`, `role` |
| `withFeaturePermission` | adds `organizationId` |
| `withAdminPermission` | adds `permission` (granted permissions) |

## Creating Custom Middleware

Build custom middleware when you need to add context or perform checks not covered by the built-in options:

```typescript
import { createMiddleware } from 'next-safe-action';
import { authenticatedActionClient } from '@kit/action-middleware';

// Custom middleware that loads user's subscription
const withSubscription = createMiddleware<{
  ctx: { user: { id: string } };
}>().define(async ({ next, ctx }) => {
  const subscription = await db
    .select()
    .from(subscription)
    .where(eq(subscription.userId, ctx.user.id))
    .limit(1);

  return next({
    ctx: {
      ...ctx,
      subscription: subscription[0] ?? null,
    },
  });
});

// Use the custom middleware
export const premiumAction = authenticatedActionClient
  .use(withSubscription)
  .inputSchema(schema)
  .action(async ({ parsedInput, ctx }) => {
    if (!ctx.subscription || ctx.subscription.status !== 'active') {
      throw new Error('Active subscription required');
    }

    // Premium feature logic
  });
```

## Middleware Chain Order

When chaining multiple middleware, they execute in order. Each middleware can access context from previous middleware:

```typescript
export const complexAction = authenticatedActionClient
  .use(withMinRole('admin'))           // 1. Checks role
  .use(withFeaturePermission({         // 2. Checks permissions
    project: ['update']
  }))
  .use(withSubscription)               // 3. Loads subscription
  .inputSchema(schema)
  .action(async ({ parsedInput, ctx }) => {
    // ctx has: user, organizationId, role, subscription
  });
```

This middleware system is part of the [Next.js Drizzle SaaS Kit](/drizzle).

---

**Next:** [Working with Forms →](./working-with-forms)
