Action Middleware

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

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:

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:

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:

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.

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.

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:

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:

'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:

'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:

'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:

'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:

ClientContext Properties
authenticatedActionClientuser
organizationActionClientuser, organizationId, role
adminActionClientuser
withMinRoleadds organizationId, role
withFeaturePermissionadds organizationId
withAdminPermissionadds 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:

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:

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.


Next: Working with Forms →