For years, form handling on the web meant writing an API endpoint, hoping the client sent the right shape, validating on the server, and hoping you didn't miss a case. Astro 5 Actions flatten this. You define a server function with a Zod schema, call it from the client, and both sides know the shape. No API routes. No guessing. No fetch('/api/...').
If you've been living under a rock, Astro Actions are the reason Velocity X can submit a contact form, update a dashboard widget, and trigger an OAuth flow without any of that feeling bolted-on.
What Actions Actually Are
An Astro Action is a server function that lives alongside your component, has a Zod schema attached, and returns a result (or an error). You define it once, call it from the client, and TypeScript knows the input type and return type on both ends. Astro serializes the call, validates the input server-side, runs your logic, and sends back the result. The whole loop is type-safe and happens without a single API route file.
A Real Form Submit
Here's a contact form in Velocity X. First, define the Action in a server file:
{`// src/actions/contact.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:content';
export const submit = defineAction({
input: z.object({
email: z.string().email('Invalid email'),
message: z.string().min(10, 'Message must be 10+ chars'),
name: z.string().min(2),
}),
handler: async (input, context) => {
// context.locals gives you auth, user, cookies, etc.
const user = context.locals.auth?.user;
// Insert into Supabase
const { error } = await context.locals.supabase
.from('contact_submissions')
.insert({
email: input.email,
message: input.message,
name: input.name,
user_id: user?.id || null,
});
if (error) throw new Error(error.message);
return { success: true, id: 'submitted' };
},
});`}
Now call it from a React component:
{`// src/components/ContactForm.tsx
import { submit } from '../actions/contact';
import { useState } from 'react';
export default function ContactForm() {
const [status, setStatus] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
try {
const result = await submit.safe({
name: fd.get('name') as string,
email: fd.get('email') as string,
message: fd.get('message') as string,
});
if (result.error) {
setStatus('Error: ' + result.error.message);
} else {
setStatus('Sent. Cheers.');
}
} catch (err) {
setStatus('Network error');
}
};
return (
);
}`}
TypeScript sees the schema. When you call submit({ ... }), the IDE autocompletes your input keys. If you pass the wrong type or miss a required field, TypeScript yells at you before runtime. On the server, the schema validates again — belt and suspenders.
Why This Beats Custom API Routes
No endpoint file. Traditional API routes live in separate folders (/api/...) with their own validation logic, often duplicated from the client. Actions live in your codebase, one definition shared by both sides.
Automatic serialization. Astro handles the fetch call under the hood. You're not writing fetch('/api/contact', { method: 'POST', body: JSON.stringify(...) }) yourself; the framework handles the wire protocol.
Shared auth context. context.locals.auth and context.locals.user come from your Astro middleware. Every Action automatically inherits your auth state — no separate JWT validation logic in each endpoint.
Error handling is built-in. Actions return a data | error discriminated union. No more checking response.ok or catching JSON parse errors; Astro does it for you.
Dashboard Mutations in Velocity X
Velocity X dashboard uses Actions for every state change: updating a pricing tier, toggling a feature flag, regenerating an API key. Each Action calls Supabase with the authenticated user ID baked in. The whole loop — client click → server mutation → response → UI update — is type-safe.
{`export const updateTier = defineAction({
input: z.object({
tierId: z.string(),
newLimit: z.number().min(1),
}),
handler: async (input, context) => {
const user = context.locals.auth.user;
// RLS policy ensures user can only update their own tiers
const { data, error } = await context.locals.supabase
.from('tiers')
.update({ limit: input.newLimit })
.eq('id', input.tierId)
.eq('user_id', user.id)
.select()
.single();
if (error) throw new Error('Unauthorized');
return data;
},
});`}
From the component, call it and the UI updates immediately:
{`const result = await updateTier.safe({
tierId: tier.id,
newLimit: 100,
});
if (result.data) {
setTiers(prev => prev.map(t =>
t.id === tierId ? { ...t, limit: result.data.limit } : t
));
}`}
Six FAQs
Can I call Actions from the server side?
Yes, but it's overkill. If you're already on the server (Server Component, SSR page), just call the function directly without going through the Action. Use Actions only when you need to call from the browser.
What happens if the network fails?
The .safe() method returns { error: ... }. You decide how to handle it — show a toast, retry, whatever. If you use await action() without .safe(), it throws.
Can I file-upload through an Action?
Yes. Pass a File object in the input schema (Astro handles the multipart encoding), and access it server-side. Velocity X uses this for avatar uploads tied to the authenticated user.
How do I handle rate limiting?
Middleware. Set rate limit headers in your Astro middleware based on Astro.locals.user.id or IP. If you're over the limit, throw an error in the Action handler and let Astro return the error to the client.
Are Actions just for REST-style mutations?
No. Query data too. Define an Action that returns your full dashboard data, call it on page load, and keep it in state. It's RPC, not REST — you're calling functions, not endpoints.
Does this work with real-time subscriptions?
Actions don't support subscriptions. Use Actions for request-response, and subscribe to Supabase Realtime channels separately for live updates.
The Bottom Line
Astro Actions are the missing piece for modern full-stack apps. They collapse the client-server boundary without sacrificing type safety, remove boilerplate API route files, and keep your auth context available everywhere. If you're building a dashboard, a SaaS site, or anything with forms and mutations, this is the pattern. Beats REST, beats GraphQL setup, beats writing API routes by hand. See it in action at Velocity X pricing, or read more about Server Islands for the static-side of the equation.