Why Astro Actions?
Astro Actions let you define type-safe server functions that you call directly from your client code. Think tRPC but built into the framework - with Zod validation, automatic error handling, and zero boilerplate.
Define an Action
// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
createPost: defineAction({
accept: 'json',
input: z.object({
title: z.string().min(1).max(200),
body: z.string().min(10),
tags: z.array(z.string()).optional(),
}),
handler: async (input, context) => {
const post = await db.posts.create({
data: { ...input, authorId: context.locals.userId },
});
return { id: post.id, slug: post.slug };
},
}),
deletePost: defineAction({
accept: 'json',
input: z.object({ id: z.string().uuid() }),
handler: async ({ id }) => {
await db.posts.delete({ where: { id } });
return { success: true };
},
}),
};
Call from Client Components
import { actions } from 'astro:actions';
export default function PostForm() {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const { data, error } = await actions.createPost({
title: formData.get('title') as string,
body: formData.get('body') as string,
});
if (error) {
console.error('Validation errors:', error.fields);
return;
}
window.location.href = `/posts/${data.slug}`;
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Post title" required />
<textarea name="body" placeholder="Write your post..." />
<button type="submit">Publish</button>
</form>
);
}
Form Actions (Progressive Enhancement)
---
import { actions } from 'astro:actions';
const result = Astro.getActionResult(actions.submitContact);
if (result && !result.error) {
return Astro.redirect('/thank-you');
}
---
<form method="POST" action={actions.submitContact}>
<input name="email" type="email" required />
<textarea name="message" required />
{result?.error && <p class="error">{result.error.message}</p>}
<button type="submit">Send</button>
</form>
Error Handling
import { ActionError, defineAction } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
subscribe: defineAction({
input: z.object({ email: z.string().email() }),
handler: async ({ email }) => {
const existing = await db.subscribers.findUnique({ where: { email } });
if (existing) {
throw new ActionError({ code: 'CONFLICT', message: 'Already subscribed' });
}
await db.subscribers.create({ data: { email } });
return { subscribed: true };
},
}),
};
File Uploads
export const server = {
uploadAvatar: defineAction({
accept: 'form',
input: z.object({
avatar: z.instanceof(File)
.refine(f => f.size < 5_000_000, 'Max 5MB')
.refine(f => f.type.startsWith('image/'), 'Must be an image'),
}),
handler: async ({ avatar }, context) => {
const buffer = await avatar.arrayBuffer();
const path = `avatars/${context.locals.userId}.webp`;
await storage.upload(path, buffer);
return { url: storage.getPublicUrl(path) };
},
}),
};
Real-World Use Case
A developer building a content site on Astro needed form handling without spinning up a separate API. With Actions, they defined 5 server functions in one file - contact form, newsletter signup, comment submission, like button, and search. Full type safety from form to database, Zod validation catches bad input before it hits the handler.
Building with Astro? I create custom data pipelines and automation tools. Check out my web scraping toolkit on Apify or reach me at spinov001@gmail.com for custom solutions.
Top comments (0)