Action Middleware

Configure authentication and authorization middleware for server actions in your Next.js Prisma kit application.

Enforce authentication and authorization declaratively with action middleware - pre-built chains that verify sessions, check permissions, and inject context before your server action runs.

Action middleware in the Next.js Prisma kit eliminates repetitive auth checks. Instead of manually verifying sessions in every action, you choose the right action client: authenticatedActionClient for logged-in users, adminActionClient for super-admins, or chain .use(withFeaturePermission()) for granular permission checks. The middleware runs before your action and throws if checks fail.

Action middleware is a chain of functions that run before your server action. Each middleware can verify conditions, inject context, or reject the request.

  • Use authenticatedActionClient when: you need to verify the user is logged in and access their session data.
  • Use adminActionClient when: the operation is super-admin only (banning users, accessing all accounts).
  • Use withFeaturePermission when: you need to verify the user can perform a specific action on a resource.

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
// 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

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 admin privileges
  • Throws immediately if either check fails

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';
const updateProfileSchema = z.object({
name: z.string().min(1),
});
export const updateProfileAction = authenticatedActionClient
.inputSchema(updateProfileSchema)
.action(async ({ parsedInput, ctx }) => {
const { user } = ctx;
await db.user.update({
where: { id: user.id },
data: { name: parsedInput.name },
});
return { success: true };
});

With Permission Check

For organization-scoped actions, use withFeaturePermission to verify the user can perform the operation. This middleware checks the user's permissions using Better Auth and injects organization context:

import { authenticatedActionClient, withFeaturePermission } from '@kit/action-middleware';
export const deleteProjectAction = authenticatedActionClient
.use(withFeaturePermission({ project: ['delete'] }))
.inputSchema(z.object({ projectId: z.string() }))
.action(async ({ parsedInput, ctx }) => {
// Permission already verified by middleware
// ctx.organizationId is available
// Delete project
});

Alternatively, withMinRole checks role hierarchy directly. Use this when you need "admin or higher" rather than specific permissions:

import { authenticatedActionClient, withMinRole } from '@kit/action-middleware';
export const adminAction = authenticatedActionClient
.use(withMinRole('admin'))
.inputSchema(schema)
.action(async ({ parsedInput, ctx }) => {
// Only admins and owners can reach here
});

Admin Action

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

import { adminActionClient } from '@kit/action-middleware';
export const banUserAction = adminActionClient
.inputSchema(z.object({
userId: z.string(),
reason: z.string().min(10),
}))
.action(async ({ parsedInput, ctx }) => {
// Only admins reach here
await auth.api.banUser({
body: {
userId: parsedInput.userId,
banReason: parsedInput.reason,
},
});
});

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 other errors
console.error(result.serverError);
}
};
}

Context Properties

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

interface ActionContext {
user: {
id: string;
email: string;
name: string;
role?: string;
// ... other Better Auth user fields
};
}

Creating Custom Middleware

Build custom middleware when you need to add context or perform checks not covered by the built-in options. Middleware receives the current context and returns an enriched version:

import { authenticatedActionClient } from '@kit/action-middleware';
export const orgMemberActionClient = authenticatedActionClient.use(
async ({ next, ctx }) => {
const { user } = ctx;
// Add custom validation or context enrichment
const userProfile = await getUserProfile(user.id);
if (!userProfile) {
throw new Error('Profile not found');
}
return next({
ctx: { ...ctx, profile: userProfile },
});
}
);

Common Pitfalls

  • Wrong action client: Using authenticatedActionClient when you need organization context. Use organizationActionClient or add withFeaturePermission.
  • Swallowing middleware errors: When middleware throws, the error should propagate. Don't wrap in try/catch unless you're enriching the error.
  • Expensive middleware: Middleware runs on every request. Avoid database calls in middleware unless absolutely necessary - prefer checking at the action level.
  • Forgetting to return next(): Custom middleware must call and return next() to continue the chain. Forgetting this silently breaks the action.
  • Hardcoding roles: Use withFeaturePermission for resource-based checks instead of checking ctx.role === 'admin' directly.

Frequently Asked Questions

What's the difference between withMinRole and withFeaturePermission?
withMinRole checks role hierarchy (admin can do what member can). withFeaturePermission checks specific permissions on resources (project:delete). Use withMinRole for broad access, withFeaturePermission for granular control.
Can I use multiple middleware?
Yes. Chain them with .use(): authenticatedActionClient.use(middleware1).use(middleware2). They run in order, and each can enrich ctx.
How do I create organization-scoped actions?
Use organizationActionClient or add withFeaturePermission to authenticatedActionClient. Both inject ctx.organizationId and verify membership.
What happens when middleware throws?
The action stops immediately and the error propagates to the client as result.serverError. The client should check for this and show an appropriate message.

Next: Working with Forms →