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
organizationIdandroleinto 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 actionexport 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 organizationexport 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 permissionexport 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 permissionsexport 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 permissionexport 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 permissionsexport 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:
| 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:
import { createMiddleware } from 'next-safe-action';import { authenticatedActionClient } from '@kit/action-middleware';// Custom middleware that loads user's subscriptionconst 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 middlewareexport 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 →