Astro Actions & Endpoints

[check: …]
📖 Full guide → 📖 ASTRO_ACTIONS_ENDPOINTS.md — the complete narrative, code samples, and verified sources. This page is its interactive companion.

The one idea

An Action is a type-safe server function: call it like a local async fn from the client via the actions object, and get a typed { data, error } result with Zod input validation for free. A raw API endpoint is a src/pages/api/*.ts handler that returns a Response — you hand-roll fetch, JSON, headers, status, and types.

Actions are defined with defineAction() inside a server object in src/actions/index.ts, and called through import { actions } from 'astro:actions'. Added in astro@4.15 (preview 4.8/4.10); both need on-demand (SSR) rendering.

Action: typed input (Zod) + typed return Action: no manual fetch / JSON Endpoint: returns a raw Response Endpoint: you set headers/status/types

Side by side — the same "greeting" as an Action and as an endpoint

src/actions/index.ts Action

import { defineAction } from 'astro:actions';
import { z } from 'astro/zod';

export const server = {
  getGreeting: defineAction({
    input: z.object({
      name: z.string().min(1),
    }),
    handler: async (input) => ({
      message: `Hello, ${input.name}!`,
      chars: input.name.length,
    }),
  }),
};
// client (typed — no fetch, no JSON.parse):
import { actions, isInputError } from 'astro:actions';

const { data, error } = await actions.getGreeting({ name: 'Houston' });
if (isInputError(error)) {
  console.log(error.fields.name); // ['String must contain at least 1 character(s)']
}
// data.message  <-- typed: string

src/pages/api/echo.ts API endpoint

import type { APIRoute } from 'astro';

export const POST = (async ({ request }) => {
  const body = await request.json();
  const name = body.name ?? '';
  return new Response(JSON.stringify({
    message: `Hello, ${name}!`,
    chars: name.length,
  }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
}) satisfies APIRoute;
// client (untyped — you fetch, check res.ok, parse JSON):
const res = await fetch('/api/echo', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Houston' }),
});
if (!res.ok) { /* handle status yourself */ }
const data = await res.json(); // <-- untyped: any

Try it — call the same input two ways

Type a name (or use a chip) and call it as an Action (typed + validated by a tiny inline Zod-like validator) or as a raw endpoint (no validation — echoes anything). The result + the request/response flow update live.

Pick a value and press a call button to see the simulated result.

Request / response flow

The Action lane validates input with Zod on the server and hands the client a typed { data, error }. The endpoint lane returns a Response the client must await res.json() — validation and types are on you.

Action
clientactions.getGreeting(input)
server · zodvalidate input
server · handlerreturns typed value
client{ data, error } (typed)
Endpoint
clientfetch('/api/echo', …)
server · handlernew Response(JSON…)
clientawait res.json()
clientany (untyped)

Comparison — when to reach for which

This bundle is HOW Astro does server calls: defineAction for type-safe server fns, src/pages/api/*.ts for raw endpoints. Cross-refs: 🔗 astro_react_integration (islands call actions) · 🔗 astro_rendering_modes (actions need on-demand output).