Skip to main content
Astro Actions provide type-safe server functions that can be called from client code or Astro components.

Importing

import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';

defineAction

Define a server-side action that can be called from the client or server.
defineAction(config: ActionConfig): ActionClient
config
object
required
Action configuration object.
config.input
ZodSchema
Zod schema to validate input data. If accept is 'form', defaults to FormData schema.
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';

export const server = {
  getUser: defineAction({
    input: z.object({
      userId: z.string(),
    }),
    handler: async (input) => {
      return { name: 'John Doe', id: input.userId };
    },
  }),
};
config.accept
'form' | 'json'
Type of input the action accepts. Use 'form' for form submissions or 'json' for JSON payloads.Default: 'json'
defineAction({
  accept: 'form',
  input: z.object({
    email: z.string().email(),
    message: z.string(),
  }),
  handler: async (input) => {
    // input is parsed from FormData
    return { success: true };
  },
})
config.handler
(input, context) => Promise<Output>
required
The server function that executes the action.Parameters:
input
Input
Parsed and validated input data matching your schema.
context
ActionAPIContext
Server context with request information.
context.cookies
AstroCookies
Cookie utilities.
context.request
Request
The original request object.
context.url
URL
Request URL.
context.clientAddress
string
Client IP address.
context.locals
App.Locals
Shared locals object.
handler: async ({ userId }, context) => {
  const user = await db.users.findOne({ id: userId });
  
  if (!user) {
    throw new Error('User not found');
  }

  return user;
}

Example

// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';

export const server = {
  createPost: defineAction({
    accept: 'form',
    input: z.object({
      title: z.string(),
      content: z.string(),
      published: z.boolean().optional(),
    }),
    handler: async (input, context) => {
      const user = context.locals.user;
      
      if (!user) {
        throw new Error('Not authenticated');
      }

      const post = await db.posts.create({
        ...input,
        authorId: user.id,
      });

      return { id: post.id };
    },
  }),

  getPost: defineAction({
    input: z.object({
      postId: z.string(),
    }),
    handler: async ({ postId }) => {
      const post = await db.posts.findOne({ id: postId });
      
      if (!post) {
        throw new Error('Post not found');
      }

      return post;
    },
  }),
};

Calling Actions

From Client-Side JavaScript

import { actions } from 'astro:actions';

const result = await actions.getPost({ postId: '123' });

if (result.error) {
  console.error(result.error);
} else {
  console.log(result.data);
}

From Forms

---
import { actions } from 'astro:actions';
---

<form method="POST" action={actions.createPost}>
  <input type="text" name="title" required />
  <textarea name="content" required></textarea>
  <button type="submit">Create Post</button>
</form>

From Astro Components

Use Astro.callAction() to call actions server-side:
---
import { actions } from 'astro:actions';

const result = await Astro.callAction(actions.getPost, { 
  postId: Astro.params.id 
});

if (result.error) {
  return Astro.redirect('/404');
}

const post = result.data;
---

<h1>{post.title}</h1>
<div>{post.content}</div>

Getting Form Results

Use Astro.getActionResult() to get results from form submissions:
---
import { actions } from 'astro:actions';

const result = Astro.getActionResult(actions.createPost);
---

{result?.error && (
  <p class="error">{result.error.message}</p>
)}

{result?.data && (
  <p class="success">Post created with ID: {result.data.id}</p>
)}

<form method="POST" action={actions.createPost}>
  <input type="text" name="title" required />
  <textarea name="content" required></textarea>
  <button type="submit">Create Post</button>
</form>

Error Handling

Actions return a SafeResult object with either data or error:
type SafeResult<TOutput> = 
  | { data: TOutput; error: undefined }
  | { data: undefined; error: ActionError };

Throwing Errors

Throw errors in your handler to return error responses:
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro:schema';

export const server = {
  deletePost: defineAction({
    input: z.object({ postId: z.string() }),
    handler: async ({ postId }, context) => {
      const user = context.locals.user;
      
      if (!user) {
        throw new ActionError({
          code: 'UNAUTHORIZED',
          message: 'You must be logged in to delete posts',
        });
      }

      const post = await db.posts.findOne({ id: postId });
      
      if (!post) {
        throw new ActionError({
          code: 'NOT_FOUND',
          message: 'Post not found',
        });
      }

      if (post.authorId !== user.id) {
        throw new ActionError({
          code: 'FORBIDDEN',
          message: 'You can only delete your own posts',
        });
      }

      await db.posts.delete({ id: postId });
      return { success: true };
    },
  }),
};

Error Codes

code
string
HTTP-like error codes:
  • 'BAD_REQUEST' - Invalid input (400)
  • 'UNAUTHORIZED' - Authentication required (401)
  • 'FORBIDDEN' - Insufficient permissions (403)
  • 'NOT_FOUND' - Resource not found (404)
  • 'TIMEOUT' - Request timeout (408)
  • 'CONFLICT' - Resource conflict (409)
  • 'PRECONDITION_FAILED' - Precondition not met (412)
  • 'PAYLOAD_TOO_LARGE' - Request too large (413)
  • 'UNSUPPORTED_MEDIA_TYPE' - Invalid content type (415)
  • 'UNPROCESSABLE_CONTENT' - Validation failed (422)
  • 'TOO_MANY_REQUESTS' - Rate limit exceeded (429)
  • 'CLIENT_CLOSED_REQUEST' - Client cancelled (499)
  • 'INTERNAL_SERVER_ERROR' - Server error (500)
  • 'NOT_IMPLEMENTED' - Not implemented (501)
  • 'BAD_GATEWAY' - Bad gateway (502)
  • 'SERVICE_UNAVAILABLE' - Service unavailable (503)
  • 'GATEWAY_TIMEOUT' - Gateway timeout (504)

Input Validation

Input is automatically validated using the provided Zod schema:
export const server = {
  updateProfile: defineAction({
    accept: 'form',
    input: z.object({
      name: z.string().min(2).max(50),
      email: z.string().email(),
      age: z.number().int().min(18).optional(),
    }),
    handler: async (input) => {
      // input is fully typed and validated
      return { success: true };
    },
  }),
};
Validation errors are returned automatically:
const result = await actions.updateProfile({
  name: 'A', // Too short
  email: 'invalid', // Invalid email
});

if (result.error) {
  console.log(result.error.fields); // Validation errors by field
}

orThrow

Call .orThrow() to throw on errors instead of returning a result object:
try {
  const post = await actions.getPost.orThrow({ postId: '123' });
  // post is TOutput, not SafeResult
  console.log(post.title);
} catch (error) {
  console.error(error);
}
This is useful for server-side code where you want exceptions instead of result objects.

Build docs developers (and LLMs) love