Guide

Defining Actions

Define type-safe server actions with Zod input and output validation, file-based routing, HTTP method suffixes, and metadata.

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

server/actions/greet.ts
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 via safeAction.actionsDir)
  • Each file must default-export an action
  • The file name becomes the import name (kebab-case is converted to camelCase)
FileImport Name
server/actions/greet.tsgreet
server/actions/create-post.tscreatePost
server/actions/get-user-profile.tsgetUserProfile

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:

FileMethodRoute
server/actions/create-post.tsPOST/api/_actions/create-post
server/actions/get-user.get.tsGET/api/_actions/get-user
server/actions/update-user.put.tsPUT/api/_actions/update-user
server/actions/update-field.patch.tsPATCH/api/_actions/update-field
server/actions/remove-item.delete.tsDELETE/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.

GET actions receive input via query parameters (?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:

server/actions/create-post.ts
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:

server/actions/get-user.ts
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:

server/actions/admin-action.ts
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():

server/actions/get-stats.ts
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:

server/actions/get-user.get.ts
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.

Copyright © 2026