Skip to content

createActionClient() ​

Create a builder-based action client for composing actions with shared middleware, schemas, and metadata. Each method returns a new immutable instance, making it safe to share and extend base clients across multiple actions.

Type Signature ​

ts
function createActionClient<TCtx = Record<string, never>>(
  opts?: { middleware?: ActionMiddleware[] }
): ActionClient<TCtx>

Type Parameters ​

ParameterDefaultDescription
TCtxRecord<string, never>The accumulated context type. Starts empty and grows as .use() adds middleware that extends context.

Constructor Options ​

ts
interface CreateActionClientOptions {
  /** Pre-seed the client with middleware. Equivalent to calling .use() for each entry. */
  middleware?: ActionMiddleware[]
}
OptionTypeRequiredDefaultDescription
middlewareActionMiddleware[]No[]An initial array of middleware to include in the chain.

Example: Constructor with Middleware ​

ts
// Equivalent to createActionClient().use(authMiddleware).use(rateLimitMiddleware)
const client = createActionClient({
  middleware: [authMiddleware, rateLimitMiddleware],
})

ActionClient Methods ​

The ActionClient<TCtx> interface exposes four methods. Each returns a new instance (immutable builder pattern).

ts
interface ActionClient<TCtx = Record<string, never>> {
  use: <TNewCtx extends Record<string, unknown>>(
    middleware: ActionMiddleware<TCtx, TNewCtx>,
  ) => ActionClient<TCtx & TNewCtx>

  schema: <TInputSchema extends StandardSchemaV1>(
    inputSchema: TInputSchema,
  ) => ActionClientWithSchema<TCtx, TInputSchema>

  metadata: (meta: ActionMetadata) => ActionClient<TCtx>

  action: <TOutput>(
    handler: ActionHandler<unknown, TOutput, TCtx>,
  ) => EventHandler
}

.use(middleware) ​

Add a middleware function to the chain. Returns a new ActionClient with the context type extended by the middleware output.

  • Parameter: middleware: ActionMiddleware<TCtx, TNewCtx>
  • Returns: ActionClient<TCtx & TNewCtx>

Middleware is called in the order it was added. Each middleware can augment the context passed to subsequent middleware and the handler.

ts
const authClient = createActionClient()
  .use(authMiddleware)      // TCtx becomes { user: User }
  .use(rateLimitMiddleware) // TCtx becomes { user: User } & { rateLimit: RateLimitInfo }

Type inference flows through the chain, so the handler receives the combined context:

ts
authClient
  .schema(z.object({ title: z.string() }))
  .action(async ({ input, ctx }) => {
    // ctx.user    -- typed from authMiddleware
    // ctx.rateLimit -- typed from rateLimitMiddleware
    return { id: 1, title: input.title, owner: ctx.user.id }
  })

.schema(inputSchema) ​

Set the input validation schema. Transitions the client from ActionClient to ActionClientWithSchema, which unlocks .outputSchema() and changes the .action() handler input type.

  • Parameter: inputSchema: TInputSchema extends StandardSchemaV1
  • Returns: ActionClientWithSchema<TCtx, TInputSchema>
ts
const handler = createActionClient()
  .use(authMiddleware)
  .schema(z.object({
    title: z.string().min(1),
    priority: z.enum(['low', 'medium', 'high']),
  }))
  .action(async ({ input }) => {
    // input is typed as { title: string; priority: 'low' | 'medium' | 'high' }
    return { id: Date.now(), ...input }
  })

.metadata(meta) ​

Attach metadata to the action. Metadata is merged with any previously set metadata. Available on both ActionClient and ActionClientWithSchema.

  • Parameter: meta: ActionMetadata (alias for Record<string, unknown>)
  • Returns: ActionClient<TCtx> (same context type)
ts
const client = createActionClient()
  .use(authMiddleware)
  .metadata({ role: 'admin', action: 'list-users' })

.action(handler) (without schema) ​

Terminal method. Creates an H3 event handler from the accumulated configuration. When called on an ActionClient (no .schema() was used), the handler input is unknown.

  • Parameter: handler: ActionHandler<unknown, TOutput, TCtx>
  • Returns: EventHandler
ts
// server/api/health.get.ts
export default createActionClient()
  .action(async ({ event }) => {
    return { status: 'ok', timestamp: Date.now() }
  })

ActionClientWithSchema Methods ​

After calling .schema(), the client transitions to ActionClientWithSchema<TCtx, TInputSchema>. This interface exposes three methods.

ts
interface ActionClientWithSchema<TCtx, TInputSchema extends StandardSchemaV1> {
  outputSchema: <TOutputSchema extends StandardSchemaV1>(
    schema: TOutputSchema,
  ) => ActionClientWithSchema<TCtx, TInputSchema>

  metadata: (meta: ActionMetadata) => ActionClientWithSchema<TCtx, TInputSchema>

  action: <TOutput>(
    handler: ActionHandler<InferOutput<TInputSchema>, TOutput, TCtx>,
  ) => EventHandler
}

.outputSchema(schema) ​

Set the output validation schema. The handler return value is validated against this schema at runtime. If validation fails, the action returns code: 'OUTPUT_VALIDATION_ERROR' with statusCode: 500.

  • Parameter: schema: TOutputSchema extends StandardSchemaV1
  • Returns: ActionClientWithSchema<TCtx, TInputSchema>
ts
export default authClient
  .schema(z.object({ id: z.string() }))
  .outputSchema(z.object({
    id: z.string(),
    name: z.string(),
    email: z.string().email(),
  }))
  .action(async ({ input }) => {
    return await db.user.findUnique({ where: { id: input.id } })
  })

.metadata(meta) ​

Identical to the ActionClient.metadata() method. Merges metadata into the builder configuration.

  • Parameter: meta: ActionMetadata
  • Returns: ActionClientWithSchema<TCtx, TInputSchema>

.action(handler) (with schema) ​

Terminal method. Creates an H3 event handler. The handler input parameter is fully typed based on the schema provided to .schema().

  • Parameter: handler: ActionHandler<InferOutput<TInputSchema>, TOutput, TCtx>
  • Returns: EventHandler
ts
export default authClient
  .schema(z.object({ title: z.string() }))
  .action(async ({ input, ctx }) => {
    // input.title is typed as string
    // ctx.user is typed from authMiddleware
    return await db.todo.create({
      data: { title: input.title, userId: ctx.user.id },
    })
  })

Type Inference Through the Chain ​

The builder pattern preserves full type information at each step. Context types are intersected as middleware is added, and input types are inferred from the schema.

ts
// Step 1: Empty context
const base = createActionClient()
// ActionClient<Record<string, never>>

// Step 2: Auth middleware adds { user: User }
const withAuth = base.use(authMiddleware)
// ActionClient<Record<string, never> & { user: User }>

// Step 3: Logging middleware adds { requestId: string }
const withLogging = withAuth.use(loggingMiddleware)
// ActionClient<Record<string, never> & { user: User } & { requestId: string }>

// Step 4: Schema locks the input type
const withSchema = withLogging.schema(z.object({ title: z.string() }))
// ActionClientWithSchema<... , z.ZodObject<{ title: z.ZodString }>>

// Step 5: Terminal -- handler receives fully typed input and ctx
export default withSchema.action(async ({ input, ctx }) => {
  // input: { title: string }
  // ctx: { user: User; requestId: string }
  return { id: 1, title: input.title }
})

Builder Flow Diagram ​

createActionClient(opts?)
  |
  +-- .use(middleware)      -> ActionClient (extended ctx)
  |     (chainable)
  |
  +-- .metadata(meta)       -> ActionClient (same ctx)
  |     (chainable)
  |
  +-- .schema(inputSchema)  -> ActionClientWithSchema
  |     |
  |     +-- .outputSchema(schema) -> ActionClientWithSchema
  |     |     (chainable)
  |     |
  |     +-- .metadata(meta)       -> ActionClientWithSchema
  |     |     (chainable)
  |     |
  |     +-- .action(handler)      -> EventHandler [terminal]
  |
  +-- .action(handler)      -> EventHandler [terminal, input = unknown]

Examples ​

Shared Auth and Admin Clients ​

ts
// server/utils/action-clients.ts
export const authClient = createActionClient()
  .use(authMiddleware)
  .use(rateLimitMiddleware)

export const adminClient = createActionClient()
  .use(authMiddleware)
  .use(adminMiddleware)

Using a Shared Client in an API Route ​

ts
// server/api/todos.post.ts
import { z } from 'zod'
import { authClient } from '~/server/utils/action-clients'

export default authClient
  .schema(z.object({
    title: z.string().min(1),
  }))
  .action(async ({ input, ctx }) => {
    return await db.todo.create({
      data: { title: input.title, userId: ctx.user.id },
    })
  })

Admin Route with Metadata and Output Validation ​

ts
// server/api/admin/users.get.ts
import { z } from 'zod'
import { adminClient } from '~/server/utils/action-clients'

export default adminClient
  .schema(z.object({
    page: z.coerce.number().default(1),
    limit: z.coerce.number().default(10),
  }))
  .outputSchema(z.array(z.object({
    id: z.string(),
    name: z.string(),
    email: z.string().email(),
    role: z.enum(['user', 'admin']),
  })))
  .metadata({ role: 'admin', action: 'list-users' })
  .action(async ({ input, ctx }) => {
    return await db.user.findMany({
      skip: (input.page - 1) * input.limit,
      take: input.limit,
    })
  })

Action Without Schema ​

ts
// server/api/health.get.ts
export default createActionClient()
  .use(loggingMiddleware)
  .action(async ({ event, ctx }) => {
    return { status: 'ok', requestId: ctx.requestId }
  })

Auto-Import ​

createActionClient is auto-imported in all server routes (server/) when the nuxt-actions module is installed. No manual import is needed.

See Also ​

Released under the MIT License.