Skip to main content
Actions are type-safe server functions that handle form submissions, API calls, and other backend operations. They provide automatic validation, error handling, and progressive enhancement for forms.

Defining Actions

Create actions in src/actions/index.ts by exporting a server object:
src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';

export const server = {
  subscribe: defineAction({
    input: z.object({
      email: z.string().email()
    }),
    handler: async ({ email }) => {
      // Your server logic here
      await db.subscribers.create({ email });
      
      return {
        success: true,
        message: 'Subscribed successfully!'
      };
    }
  })
};
Actions run exclusively on the server. Client code cannot see or execute handler logic directly.

Using Actions in Forms

Call actions from forms with automatic progressive enhancement:
src/pages/newsletter.astro
---
import { actions } from 'astro:actions';

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

<html>
  <body>
    <form method="POST" action={actions.subscribe}>
      <input type="email" name="email" required />
      <button type="submit">Subscribe</button>
    </form>

    {result?.success && <p>{result.data.message}</p>}
    {result?.error && <p>Error: {result.error.message}</p>}
  </body>
</html>
Forms work without JavaScript but get enhanced with client-side validation and handling when JS is available.

Input Validation

Use Zod schemas to validate input automatically:
src/actions/index.ts
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro:schema';

export const server = {
  createPost: defineAction({
    input: z.object({
      title: z.string().min(3).max(100),
      content: z.string().min(10),
      tags: z.array(z.string()).optional(),
      published: z.boolean().default(false)
    }),
    handler: async ({ title, content, tags, published }) => {
      // Input is validated and typed
      const post = await db.posts.create({
        title,
        content,
        tags: tags ?? [],
        published
      });

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

Validation Errors

Invalid input automatically returns typed errors:
src/pages/create-post.astro
---
import { actions } from 'astro:actions';

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

<form method="POST" action={actions.createPost}>
  <input name="title" required />
  {result?.error?.fields?.title && (
    <p class="error">{result.error.fields.title}</p>
  )}

  <textarea name="content" required></textarea>
  {result?.error?.fields?.content && (
    <p class="error">{result.error.fields.content}</p>
  )}

  <button type="submit">Create Post</button>
</form>

Form Data Handling

For file uploads and complex forms, use accept: 'form':
src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';

export const server = {
  uploadImage: defineAction({
    accept: 'form',
    input: z.object({
      image: z.instanceof(File),
      caption: z.string().optional()
    }),
    handler: async ({ image, caption }) => {
      // Handle file upload
      const buffer = await image.arrayBuffer();
      const url = await storage.upload(buffer, image.name);

      return { url, caption };
    }
  })
};
Use in a form:
<form method="POST" action={actions.uploadImage} enctype="multipart/form-data">
  <input type="file" name="image" accept="image/*" required />
  <input type="text" name="caption" placeholder="Caption" />
  <button type="submit">Upload</button>
</form>

Client-Side Actions

Call actions from client-side JavaScript:
src/pages/interactive.astro
---
import { actions } from 'astro:actions';
---

<form id="subscribe-form">
  <input type="email" name="email" required />
  <button type="submit">Subscribe</button>
</form>

<div id="result"></div>

<script>
  import { actions } from 'astro:actions';

  const form = document.getElementById('subscribe-form');
  const result = document.getElementById('result');

  form.addEventListener('submit', async (e) => {
    e.preventDefault();

    const formData = new FormData(form);
    const { data, error } = await actions.subscribe(formData);

    if (error) {
      result.textContent = `Error: ${error.message}`;
    } else {
      result.textContent = data.message;
      form.reset();
    }
  });
</script>
Client-side actions require JavaScript. Always provide a fallback form that works without JS.

Error Handling

Throw ActionError for expected errors:
src/actions/index.ts
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro:schema';

export const server = {
  login: defineAction({
    input: z.object({
      email: z.string().email(),
      password: z.string().min(8)
    }),
    handler: async ({ email, password }, context) => {
      const user = await db.users.findByEmail(email);

      if (!user || !await verifyPassword(password, user.passwordHash)) {
        throw new ActionError({
          code: 'UNAUTHORIZED',
          message: 'Invalid email or password'
        });
      }

      // Set session cookie
      context.cookies.set('session', user.sessionId, {
        httpOnly: true,
        secure: true,
        maxAge: 60 * 60 * 24 * 7 // 1 week
      });

      return { user };
    }
  })
};

Error Codes

BAD_REQUEST
400
Invalid request format
UNAUTHORIZED
401
Authentication required
FORBIDDEN
403
Insufficient permissions
NOT_FOUND
404
Resource not found
TIMEOUT
408
Request timeout
CONFLICT
409
Resource conflict
INTERNAL_SERVER_ERROR
500
Server error

Context Object

Access request context in action handlers:
src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';

export const server = {
  updateProfile: defineAction({
    input: z.object({
      name: z.string()
    }),
    handler: async ({ name }, context) => {
      // Access cookies
      const userId = context.cookies.get('userId')?.value;

      // Access request data
      console.log('User agent:', context.request.headers.get('user-agent'));
      console.log('Client IP:', context.clientAddress);

      // Access shared locals from middleware
      const user = context.locals.user;

      if (!user) {
        throw new ActionError({
          code: 'UNAUTHORIZED',
          message: 'Please log in'
        });
      }

      await db.users.update(user.id, { name });

      return { success: true };
    }
  })
};

JSON Actions

By default, actions accept JSON input:
src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';

export const server = {
  createComment: defineAction({
    // JSON is the default accept type
    input: z.object({
      postId: z.string(),
      content: z.string().min(1).max(1000)
    }),
    handler: async ({ postId, content }, context) => {
      const user = context.locals.user;
      if (!user) {
        throw new ActionError({
          code: 'UNAUTHORIZED',
          message: 'Must be logged in to comment'
        });
      }

      const comment = await db.comments.create({
        postId,
        userId: user.id,
        content
      });

      return comment;
    }
  })
};
Call from client code:
import { actions } from 'astro:actions';

const { data, error } = await actions.createComment({
  postId: '123',
  content: 'Great post!'
});

if (error) {
  console.error(error.message);
} else {
  console.log('Comment created:', data);
}

Progressive Enhancement

Actions work without JavaScript but enhance when available:
src/pages/contact.astro
---
import { actions } from 'astro:actions';

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

<html>
  <body>
    <form method="POST" action={actions.contact} id="contact-form">
      <input name="name" required />
      <input type="email" name="email" required />
      <textarea name="message" required></textarea>
      <button type="submit">Send</button>
    </form>

    {result?.success && <p>Message sent!</p>}
    {result?.error && <p>Error: {result.error.message}</p>}

    <script>
      import { actions } from 'astro:actions';

      const form = document.getElementById('contact-form');

      form?.addEventListener('submit', async (e) => {
        e.preventDefault();
        const formData = new FormData(form);
        const { data, error } = await actions.contact(formData);

        if (error) {
          alert(`Error: ${error.message}`);
        } else {
          alert('Message sent successfully!');
          form.reset();
        }
      });
    </script>
  </body>
</html>
  • Form submits via POST request
  • Page reloads with action result
  • Result available via getActionResult()
  • Form prevents default submission
  • Action called via client-side JavaScript
  • No page reload required
  • Smooth user experience

Type Safety

Actions are fully typed:
import { actions } from 'astro:actions';

// Input is typed from your schema
const result = await actions.createPost({
  title: 'My Post',
  content: 'Content here',
  published: true
});

// Result is typed
if (result.data) {
  console.log(result.data.id); // string
}

if (result.error) {
  console.log(result.error.message); // string
  console.log(result.error.code); // ErrorCode
  console.log(result.error.fields); // Field errors
}

Organizing Actions

For larger projects, organize actions into separate files:
src/actions/index.ts
import { posts } from './posts';
import { users } from './users';
import { comments } from './comments';

export const server = {
  ...posts,
  ...users,
  ...comments
};
src/actions/posts.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';

export const posts = {
  createPost: defineAction({
    input: z.object({
      title: z.string(),
      content: z.string()
    }),
    handler: async ({ title, content }) => {
      // Implementation
    }
  }),

  deletePost: defineAction({
    input: z.object({
      id: z.string()
    }),
    handler: async ({ id }) => {
      // Implementation
    }
  })
};

Advanced Patterns

<script>
  import { actions } from 'astro:actions';

  async function likePost(postId: string) {
    // Optimistically update UI
    const button = document.querySelector(`[data-post="${postId}"]`);
    button?.classList.add('liked');

    // Call action
    const { error } = await actions.likePost({ postId });

    // Revert on error
    if (error) {
      button?.classList.remove('liked');
      alert('Failed to like post');
    }
  }
</script>

Middleware

Add authentication and shared logic

API Routes

Build custom API endpoints

SSR

Server-side rendering

Type Safety

TypeScript configuration

Build docs developers (and LLMs) love