Guide

Middleware

Chain composable middleware for authentication, authorization, logging, and rate limiting with fully typed context and H3Event access.

Middleware

Middleware lets you run code before your action handler executes. Use it for authentication, authorization, logging, rate limiting, and more.

Basic Middleware

Add middleware to a client with .use():

actionClient.use(async ({ ctx, next, event, metadata, clientInput }) => {
  // Run code before the action
  console.log('Action called at:', new Date())

  // Must call next() to continue the chain
  return next({ ctx })
})

Middleware Parameters

ParameterTypeDescription
ctxobjectContext from previous middleware in the chain
nextfunctionCall to continue to the next middleware or action handler
eventH3EventThe H3 request event with full request access
metadataobjectMetadata attached via .metadata()
clientInputunknownRaw input before Zod validation
Middleware must always call next(). If it doesn't, the action will throw an error.

Authentication Middleware

The most common use case — ensure the user is authenticated:

server/utils/action-client.ts
export const authActionClient = actionClient
  .use(async ({ next, event }) => {
    const session = await getUserSession(event)
    if (!session) {
      throw new Error('Unauthorized')
    }
    return next({ ctx: { userId: session.user.id, email: session.user.email } })
  })

The ctx object is now typed with userId and email in all downstream middleware and the action handler.

Chaining Middleware

Middleware composes — each .use() adds to the chain:

export const adminActionClient = actionClient
  // First: check authentication
  .use(async ({ next, event }) => {
    const session = await getUserSession(event)
    if (!session) throw new Error('Unauthorized')
    return next({ ctx: { userId: session.user.id } })
  })
  // Second: check admin role
  .use(async ({ next, ctx }) => {
    const user = await db.user.findUnique({ where: { id: ctx.userId } })
    if (user?.role !== 'admin') throw new Error('Forbidden')
    return next({ ctx: { ...ctx, isAdmin: true } })
  })

Logging Middleware

actionClient.use(async ({ next, ctx, metadata }) => {
  const start = Date.now()
  const result = await next({ ctx })
  const duration = Date.now() - start
  console.log(`Action completed in ${duration}ms`)
  return result
})

Metadata-based Middleware

Use .metadata() on actions and read it in middleware:

// Client with role-checking middleware
export const rbacClient = actionClient
  .use(async ({ next, event, metadata }) => {
    const session = await getUserSession(event)
    if (!session) throw new Error('Unauthorized')

    if (metadata.requiredRole) {
      const user = await db.user.findUnique({ where: { id: session.user.id } })
      if (user?.role !== metadata.requiredRole) {
        throw new Error('Forbidden')
      }
    }

    return next({ ctx: { userId: session.user.id } })
  })
server/actions/delete-user.ts
export default rbacClient
  .metadata({ requiredRole: 'admin' })
  .schema(z.object({ userId: z.string() }))
  .action(async ({ parsedInput, ctx }) => {
    await db.user.delete({ where: { id: parsedInput.userId } })
    return { deleted: true }
  })

H3Event Access

The event parameter gives you full access to the H3 request context:

actionClient.use(async ({ next, event }) => {
  // Read headers
  const userAgent = getHeader(event, 'user-agent')

  // Read cookies
  const token = getCookie(event, 'auth-token')

  // Access request info
  const ip = getRequestIP(event)

  return next({ ctx: { userAgent, ip } })
})
Copyright © 2026