Skip to content

Architecture — June 2026

Astro Actions — Type-Safe Server Functions Without an API Layer

Call Server Functions From the Client — Typed, Validated, Zero API Routes

🔗 ⚙️

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 (