Defining Actions
Defining Actions
Actions are defined in server/actions/ and default-exported. Each file becomes an action that can be imported on the client.
Basic Action
import { z } from 'zod'
import { actionClient } from '../utils/action-client'
export default actionClient
.schema(z.object({
name: z.string().min(1, 'Name is required'),
}))
.action(async ({ parsedInput }) => {
return { greeting: `Hello, ${parsedInput.name}!` }
})
File Conventions
- Actions live in
server/actions/(configurable viasafeAction.actionsDir) - Each file must default-export an action
- The file name becomes the import name (kebab-case is converted to camelCase)
| File | Import Name |
|---|---|
server/actions/greet.ts | greet |
server/actions/create-post.ts | createPost |
server/actions/get-user-profile.ts | getUserProfile |
HTTP Method Suffixes
By default, actions use POST. To use a different HTTP method, add a method suffix to the filename — the same convention Nuxt uses for server/api/ routes:
| File | Method | Route |
|---|---|---|
server/actions/create-post.ts | POST | /api/_actions/create-post |
server/actions/get-user.get.ts | GET | /api/_actions/get-user |
server/actions/update-user.put.ts | PUT | /api/_actions/update-user |
server/actions/update-field.patch.ts | PATCH | /api/_actions/update-field |
server/actions/remove-item.delete.ts | DELETE | /api/_actions/remove-item |
Supported suffixes: .get, .post, .put, .patch, .delete. The method suffix is stripped from the import name — get-user.get.ts is imported as getUser.
?input=...) instead of a request body. This is handled automatically by useAction.With Validation
Use .schema() to validate input with Zod before the handler runs:
import { z } from 'zod'
import { actionClient } from '../utils/action-client'
export default actionClient
.schema(z.object({
title: z.string().min(1, 'Title is required').max(200),
body: z.string().min(1, 'Body is required'),
tags: z.array(z.string()).optional(),
}))
.action(async ({ parsedInput }) => {
const post = await db.post.create({ data: parsedInput })
return { id: post.id, title: post.title }
})
If validation fails, the errors are returned as validationErrors on the client — no exception is thrown.
With Output Validation
Use .outputSchema() to validate the return value of your action:
import { z } from 'zod'
import { actionClient } from '../utils/action-client'
export default actionClient
.schema(z.object({ id: z.string() }))
.outputSchema(z.object({
name: z.string(),
email: z.string().email(),
}))
.action(async ({ parsedInput }) => {
const user = await db.user.findUnique({ where: { id: parsedInput.id } })
return { name: user.name, email: user.email }
})
With Metadata
Attach metadata to actions that middleware can read:
import { z } from 'zod'
import { actionClient } from '../utils/action-client'
export default actionClient
.metadata({ requiredRole: 'admin' })
.schema(z.object({ userId: z.string() }))
.action(async ({ parsedInput }) => {
// Only runs if middleware allows it
await db.user.delete({ where: { id: parsedInput.userId } })
return { deleted: true }
})
Without Validation
If your action doesn't need input, skip .schema():
import { actionClient } from '../utils/action-client'
export default actionClient
.action(async () => {
const count = await db.user.count()
return { userCount: count }
})
GET Action
Use a .get.ts suffix for read-only actions that should use GET requests:
import { z } from 'zod'
import { actionClient } from '../utils/action-client'
export default actionClient
.schema(z.object({
id: z.string().min(1, 'User ID is required'),
}))
.action(async ({ parsedInput }) => {
const user = await db.user.findUnique({ where: { id: parsedInput.id } })
if (!user) throw new ActionError('User not found')
return { id: user.id, name: user.name, email: user.email }
})
The action definition is identical to POST — only the filename changes. On the client, useAction automatically serializes input as a query parameter for GET actions.