nuxt-actions
nuxt-actions
Documentation | Playground | Example
Type-safe server actions for Nuxt with Standard Schema validation, middleware, builder pattern, and optimistic updates.
Works with Zod, Valibot, ArkType, and any Standard Schema compliant library.
Features
- Standard Schema - Use Zod, Valibot, ArkType, or any compliant validation library
- E2E Type Inference - Import typed action references from
#actionswith zero manual generics - Builder Pattern -
createActionClient()for composing actions with shared middleware - Optimistic Updates -
useOptimisticActionwith race-safe rollback - SSR Queries -
useActionQuerywrapsuseAsyncDatafor SSR, caching, and reactive re-fetching - Streaming Actions -
defineStreamAction+useStreamActionfor real-time AI/streaming use cases - Retry/Backoff - Native ofetch retry with
retry: true | number | { count, delay, statusCodes } - Request Deduplication -
dedupe: 'cancel' | 'defer'to prevent duplicate requests - Custom Headers - Per-request auth tokens via static headers or function
- HMR Type Updates - Action file changes update types without restarting dev server
- DevTools Tab - Nuxt DevTools integration showing registered actions
- Security Hardened - Prototype pollution protection, error message sanitization, double
next()prevention - Output Validation - Validate server responses, not just inputs
- Middleware Chain - Reusable, composable middleware with typed context accumulation
- Type Tests - 24 compile-time type tests verifying type inference correctness
- Zero Config - Auto-imported, works out of the box
Quick Setup
Install the module:
npx nuxi module add nuxt-actions
Then install your preferred validation library:
# Zod (most popular)
pnpm add zod
# Valibot (smallest bundle)
pnpm add valibot
# ArkType (fastest runtime)
pnpm add arktype
That's it! All utilities are auto-imported.
Usage
Simple Mode: defineAction
Create type-safe API routes with automatic input validation:
// server/api/todos.post.ts
import { z } from 'zod'
export default defineAction({
input: z.object({
title: z.string().min(1, 'Title is required'),
}),
handler: async ({ input }) => {
const todo = await db.todo.create({ data: input })
return todo
},
})
Works with any Standard Schema library:
// With Valibot
import * as v from 'valibot'
export default defineAction({
input: v.object({ title: v.pipe(v.string(), v.minLength(1)) }),
handler: async ({ input }) => ({ id: Date.now(), title: input.title }),
})
// With ArkType
import { type } from 'arktype'
export default defineAction({
input: type({ title: 'string > 0' }),
handler: async ({ input }) => ({ id: Date.now(), title: input.title }),
})
Builder Mode: createActionClient
Share middleware, metadata, and configuration across actions:
// server/utils/action-clients.ts
export const authClient = createActionClient()
.use(authMiddleware)
.use(rateLimitMiddleware)
export const adminClient = createActionClient()
.use(authMiddleware)
.use(adminMiddleware)
// 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),
}))
.metadata({ role: 'admin', action: 'list-users' })
.action(async ({ input, ctx }) => {
// ctx.user and ctx.isAdmin available from middleware chain
return await db.user.findMany({
skip: (input.page - 1) * 10,
take: 10,
})
})
Output Schema Validation
Validate what your server returns, not just what it receives:
export default defineAction({
input: z.object({ id: z.string() }),
outputSchema: z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
}),
handler: async ({ input }) => {
return await db.user.findUnique({ where: { id: input.id } })
},
})
Client: useAction
Call server actions from Vue components with reactive state:
<script setup lang="ts">
const { execute, executeAsync, data, error, status, reset } = useAction<
{ title: string },
{ id: number; title: string }
>('/api/todos', {
method: 'POST',
onExecute(input) {
console.log('Sending:', input)
},
onSuccess(data) {
toast.success(`Created: ${data.title}`)
},
onError(error) {
toast.error(error.message)
},
})
// Option 1: Full result with success/error
async function handleSubmit(title: string) {
const result = await execute({ title })
if (result.success) console.log(result.data)
}
// Option 2: Direct data (throws on error)
async function handleSubmitAsync(title: string) {
try {
const todo = await executeAsync({ title })
console.log(todo)
} catch (err) {
// err is ActionError
}
}
</script>
<template>
<form @submit.prevent="handleSubmit('Buy milk')">
<button :disabled="status === 'executing'">
{{ status === 'executing' ? 'Creating...' : 'Add Todo' }}
</button>
<p v-if="error" class="error">{{ error.message }}</p>
</form>
</template>
Optimistic Updates: useOptimisticAction
Instant UI updates with automatic rollback on server error:
<script setup lang="ts">
const todos = ref([
{ id: 1, title: 'Buy milk', done: false },
{ id: 2, title: 'Walk dog', done: true },
])
const { execute, optimisticData } = useOptimisticAction('/api/todos/toggle', {
method: 'PATCH',
currentData: todos,
updateFn: (input, current) =>
current.map(t => t.id === input.id ? { ...t, done: !t.done } : t),
onError(error) {
toast.error('Failed to update - changes reverted')
},
})
</script>
<template>
<ul>
<li v-for="todo in optimisticData" :key="todo.id">
<input
type="checkbox"
:checked="todo.done"
@change="execute({ id: todo.id })"
>
{{ todo.title }}
</li>
</ul>
</template>
Middleware
Create reusable middleware for cross-cutting concerns:
// server/utils/auth.ts
export const authMiddleware = defineMiddleware(async ({ event, next }) => {
const session = await getUserSession(event)
if (!session) {
throw createActionError({
code: 'UNAUTHORIZED',
message: 'Authentication required',
statusCode: 401,
})
}
return next({ ctx: { user: session.user } })
})
Publish standalone middleware as npm packages:
// Published as `nuxt-actions-ratelimit`
export const rateLimitMiddleware = createMiddleware(async ({ event, next }) => {
await checkRateLimit(event)
return next()
})
Error Handling
Throw typed errors from handlers or middleware:
throw createActionError({
code: 'NOT_FOUND',
message: 'Todo not found',
statusCode: 404,
})
// With field-level errors
throw createActionError({
code: 'VALIDATION_ERROR',
message: 'Duplicate entry',
statusCode: 422,
fieldErrors: {
email: ['Email is already taken'],
},
})
All errors follow a consistent format:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Input validation failed",
"statusCode": 422,
"fieldErrors": {
"title": ["Title is required"],
"email": ["Invalid email address"]
}
}
}
API Reference
Server Utilities
defineAction(options)
| Option | Type | Description |
|---|---|---|
input | StandardSchema | Any Standard Schema compliant schema for input validation |
outputSchema | StandardSchema | Schema for output validation |
middleware | ActionMiddleware[] | Array of middleware functions |
metadata | Record<string, unknown> | Metadata for logging/analytics |
handler | (params) => Promise<T> | Handler receiving { input, event, ctx } |
createActionClient(options?)
| Method | Description |
|---|---|
.use(middleware) | Add middleware to the chain |
.schema(inputSchema) | Set input validation schema |
.metadata(meta) | Attach metadata |
After .schema(): | |
.outputSchema(schema) | Set output validation schema |
.metadata(meta) | Attach metadata |
.action(handler) | Terminal - creates the event handler |
defineMiddleware(fn) / createMiddleware(fn)
Define a typed middleware function. createMiddleware is an alias that signals intent for publishable middleware.
createActionError(options)
| Option | Type | Default | Description |
|---|---|---|---|
code | string | required | Error code identifier |
message | string | required | Human-readable message |
statusCode | number | 400 | HTTP status code |
fieldErrors | Record<string, string[]> | - | Field-level errors |
Client Composables
useAction<TInput, TOutput>(path, options?)
| Option | Type | Default | Description |
|---|---|---|---|
method | HttpMethod | 'POST' | HTTP method |
headers | Record<string, string> | () => Record | - | Static or dynamic headers |
retry | boolean | number | RetryConfig | false | Retry configuration |
dedupe | 'cancel' | 'defer' | - | Request deduplication |
onExecute | (input) => void | - | Called before fetch |
onSuccess | (data) => void | - | Success callback |
onError | (error) => void | - | Error callback |
onSettled | (result) => void | - | Settled callback |
Returns: { execute, executeAsync, data, error, status, isIdle, isExecuting, hasSucceeded, hasErrored, reset }
useOptimisticAction<TInput, TOutput>(path, options)
| Option | Type | Description |
|---|---|---|
method | HttpMethod | HTTP method (default: 'POST') |
headers | Record<string, string> | () => Record | Static or dynamic headers |
retry | boolean | number | RetryConfig | Retry configuration |
currentData | Ref<TOutput> | Source of truth data ref |
updateFn | (input, current) => TOutput | Optimistic update function |
Returns: { execute, optimisticData, data, error, status, isIdle, isExecuting, hasSucceeded, hasErrored, reset }
useActionQuery(action, input?, options?)
SSR-capable GET action query wrapping useAsyncData:
| Option | Type | Default | Description |
|---|---|---|---|
server | boolean | true | Run on SSR |
lazy | boolean | false | Don't block navigation |
immediate | boolean | true | Execute immediately |
default | () => T | - | Default value factory |
Returns: { data, error, status, pending, refresh, clear }
useStreamAction(action, options?)
Client composable for streaming server actions:
| Option | Type | Description |
|---|---|---|
onChunk | (chunk) => void | Called for each chunk |
onDone | (allChunks) => void | Called when stream completes |
onError | (error) => void | Called on error |
Returns: { execute, stop, chunks, data, status, error }
defineStreamAction(options)
Server-side streaming action with SSE:
| Option | Type | Description |
|---|---|---|
input | StandardSchema | Input validation schema |
middleware | ActionMiddleware[] | Middleware chain |
handler | ({ input, event, ctx, stream }) => void | Streaming handler |
Why nuxt-actions?
| Feature | nuxt-actions | trpc-nuxt | next-safe-action |
|---|---|---|---|
| Framework | Nuxt | Nuxt | Next.js |
| Standard Schema (Zod + Valibot + ArkType) | ✅ | Zod only | Zod / Yup / Valibot |
| E2E type inference | ✅ | ✅ | ✅ |
| Builder pattern | ✅ | ❌ | ✅ |
| Middleware with typed context | ✅ | ✅ | ✅ |
| Optimistic updates composable | ✅ | ❌ | ✅ |
| SSR queries | ✅ | ✅ | ❌ |
| Streaming actions (SSE) | ✅ | ❌ | ❌ |
| Retry / backoff | ✅ | ❌ | ❌ |
| Request deduplication | ✅ | ⭕ | ❌ |
| Output schema validation | ✅ | ✅ | ✅ |
| DevTools integration | ✅ | ❌ | ❌ |
| HMR type updates | ✅ | ✅ | ❌ |
| Security hardening (6 layers) | ✅ | ❌ | ❌ |
| Zero config | ✅ | ❌ | ✅ |
| Nuxt-native (no protocol layer) | ✅ | ❌ | ❌ |
Sponsors
If you find this module useful, consider supporting the project:
Contribution
Local development
# Install dependencies
pnpm install
# Generate type stubs
pnpm run dev:prepare
# Develop with the playground
pnpm run dev
# Run ESLint
pnpm run lint
# Run Vitest
pnpm run test
pnpm run test:watch
# Build the module
pnpm run prepack