a valid email"),
department: z.enum(["engineering", "sales", "support"]),
priority: z.coerce.number().min(1).max(5),
}),
handler: async ({ fullName, contactEmail, department, priority }) => {
// Server-only execution context
const ticketId = await generateTicketId();
await queueNotification({ recipient: department, payload: { fullName, contactEmail, priority } });
return { ticketId, status: "queued" };
},
}),
};
**Why this structure?**
- `accept: "form"` instructs Astro to automatically parse `multipart/form-data` or `application/x-www-form-urlencoded` payloads. You never manually call `formData.get()` or handle encoding.
- Zod schemas serve dual purposes: runtime validation and static type inference. The `handler` receives a fully typed object matching the schema exactly.
- Returning a plain object automatically serializes to JSON. The framework handles content negotiation and response headers.
### Step 2: Wire to an HTML Form (Progressive Enhancement)
The most robust integration requires zero JavaScript. Astro's `actions` import provides a URL-safe reference to your registered function.
```astro
---
// src/pages/inquiry.astro
import { actions } from "astro:actions";
---
<form method="POST" action={actions.submitInquiry}>
<input name="fullName" type="text" required />
<input name="contactEmail" type="email" required />
<select name="department">
<option value="engineering">Engineering</option>
<option value="sales">Sales</option>
<option value="support">Support</option>
</select>
<input name="priority" type="number" min="1" max="5" value="3" />
<button type="submit">Submit Inquiry</button>
</form>
Why this works: The action attribute points to an internal Astro route that matches the action name. When JavaScript is disabled, the browser performs a standard POST request. Astro intercepts it, runs validation, executes the handler, and redirects or renders a response. No client-side code is required for basic functionality.
Step 3: Enhance with Client-Side Invocation
For instant feedback without page reloads, use the .safe() method. Unlike .call(), which throws on validation failure, .safe() returns a discriminated union: { data: T, error: null } or { data: null, error: ActionError }.
// client-side script attached to the form
import { actions, isInputError } from "astro:actions";
const formElement = document.querySelector("#inquiry-form") as HTMLFormElement;
formElement.addEventListener("submit", async (event) => {
event.preventDefault();
const payload = new FormData(formElement);
const result = await actions.submitInquiry.safe(payload);
if (result.error && isInputError(result.error)) {
// result.error.fields contains typed field names and string arrays
renderFieldErrors(result.error.fields);
return;
}
if (result.data) {
showSuccessToast(`Ticket #${result.data.ticketId} created`);
formElement.reset();
}
});
Why .safe() over .call(): UI code benefits from predictable control flow. Throwing errors forces try/catch blocks that obscure business logic. The .safe() pattern aligns with functional error handling, making it trivial to branch on validation failures versus network issues.
Step 4: Integrate with Framework Islands
Actions work identically across React, Vue, Svelte, or Solid islands. The framework handles serialization, so you pass native form data or plain objects.
<!-- src/components/InquiryWidget.svelte -->
<script lang="ts">
import { actions, isInputError } from "astro:actions";
let submitting = false;
let fieldErrors: Record<string, string[]> = {};
let successMessage = "";
async function handleSubmit(event: Event) {
event.preventDefault();
submitting = true;
fieldErrors = {};
successMessage = "";
const form = event.target as HTMLFormElement;
const result = await actions.submitInquiry.safe(new FormData(form));
if (result.error && isInputError(result.error)) {
fieldErrors = result.error.fields as Record<string, string[]>;
} else if (result.data) {
successMessage = `Inquiry received. Reference: ${result.data.ticketId}`;
form.reset();
}
submitting = false;
}
</script>
<form on:submit={handleSubmit}>
<input name="fullName" type="text" />
{#if fieldErrors.fullName}
<span class="error">{fieldErrors.fullName[0]}</span>
{/if}
<input name="contactEmail" type="email" />
{#if fieldErrors.contactEmail}
<span class="error">{fieldErrors.contactEmail[0]}</span>
{/if}
<button type="submit" disabled={submitting}>
{submitting ? "Processing..." : "Submit"}
</button>
{#if successMessage}
<p class="success">{successMessage}</p>
{/if}
</form>
Why this pattern scales: The action registry acts as a single contract. When you modify the Zod schema, TypeScript immediately flags mismatches in the island component, the HTML form, and the server handler. You eliminate the drift that typically occurs when client and server types are maintained separately.
Pitfall Guide
1. Implicit Accept Type Mismatch
Explanation: Omitting accept defaults to JSON. If you submit a standard HTML form without explicitly setting accept: "form", Astro attempts to parse the payload as JSON, resulting in a 400 Bad Request before validation runs.
Fix: Always declare accept: "form" for HTML forms. Use the default (JSON) only for programmatic invocations passing plain objects.
2. Using .call() in UI Code
Explanation: .call() throws an exception on validation failure. In component render cycles or event handlers, uncaught exceptions break state updates and leave the UI in an inconsistent loading state.
Fix: Reserve .call() for server-to-server communication or test suites. Use .safe() in all client-side and island code to maintain predictable control flow.
3. Overcomplicating Zod Schemas
Explanation: Developers often mirror database models directly in action schemas. This forces clients to send fields they don't need, increases payload size, and couples UI forms to internal data structures.
Fix: Define action-specific schemas that match the exact UI contract. Use .transform() to map validated input to database models inside the handler. Keep schemas lean and purpose-built.
4. Ignoring Network-Level Errors
Explanation: isInputError() only catches Zod validation failures. Network timeouts, DNS failures, or server crashes return different error types. Treating all errors as validation errors masks infrastructure issues.
Fix: Check result.error type before narrowing. Use isInputError() for field-level feedback, and handle non-input errors with generic retry or offline UI states.
5. Duplicating Validation on the Client
Explanation: Shipping Zod schemas to the browser to validate before submission defeats the purpose of Actions. It increases bundle size and creates a second source of truth that inevitably drifts from the server schema.
Fix: Trust the server. Let Astro handle validation. Use lightweight HTML5 attributes (required, type="email", pattern) for immediate UX feedback, but rely on the action's Zod schema as the authoritative validator.
6. Forgetting to Export the server Object
Explanation: The framework expects a named export server from src/actions/index.ts. Accidentally exporting actions or routes breaks the internal resolver, resulting in 404 errors when invoking actions.
Fix: Maintain the exact export signature: export const server = { ... }. Use ESLint rules or TypeScript strict mode to catch mismatched exports early.
7. Misusing Type Guards
Explanation: isInputError() and isRuntimeError() are mutually exclusive. Calling isInputError() on a network error returns false, but developers sometimes assume the error object contains a fields property regardless.
Fix: Always guard before accessing error.fields. Structure error handling as a switch: first check isInputError, then isRuntimeError, then fallback to generic error handling.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal form submission with Zod validation | Astro Actions | Zero boilerplate, full type safety, native progressive enhancement | Low (no extra infrastructure) |
| External API consumed by third-party clients | Traditional API Routes | Requires CORS, custom headers, versioning, and public documentation | Medium (requires API gateway/middleware) |
| Real-time data streaming or WebSockets | Serverless/Edge Functions | Actions are request/response bound; not designed for persistent connections | High (requires WebSocket infrastructure) |
| File uploads with multipart parsing | Traditional API Routes | Actions currently lack native multipart/form-data file handling | Medium (requires manual stream processing) |
| Cross-framework island communication | Astro Actions | Unified contract across React, Svelte, Vue, Solid without fetch wrappers | Low (framework-agnostic) |
Configuration Template
// src/actions/index.ts
import { defineAction } from "astro:actions";
import { z } from "astro:schema";
// Shared validation utilities
const emailSchema = z.string().email("Invalid email format");
const slugSchema = z.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric");
export const server = {
createProject: defineAction({
accept: "form",
input: z.object({
projectName: z.string().min(3).max(50),
projectSlug: slugSchema,
ownerEmail: emailSchema,
visibility: z.enum(["public", "private", "internal"]),
}),
handler: async ({ projectName, projectSlug, ownerEmail, visibility }) => {
// Simulate async persistence
const projectId = crypto.randomUUID();
await persistProject({ id: projectId, name: projectName, slug: projectSlug, owner: ownerEmail, visibility });
return { projectId, status: "created" };
},
}),
updatePreferences: defineAction({
// JSON default
input: z.object({
userId: z.string().uuid(),
theme: z.enum(["light", "dark", "system"]),
notifications: z.boolean(),
}),
handler: async ({ userId, theme, notifications }) => {
await syncPreferences(userId, { theme, notifications });
return { updated: true, timestamp: Date.now() };
},
}),
};
// Middleware example (conceptual)
// Astro supports action-level middleware via hooks or wrapper functions
// for auth checks, audit logging, or rate limiting before handler execution.
Quick Start Guide
- Initialize the registry: Create
src/actions/index.ts and export a server object containing your action definitions.
- Define schemas and handlers: Use
defineAction with a Zod input schema. Set accept: "form" for HTML forms or leave as default for JSON.
- Wire the client: Import
actions from astro:actions. Point HTML form action attributes to actions.yourActionName for zero-JS fallbacks.
- Enhance with JS: Attach submit listeners that call
actions.yourActionName.safe(formData). Use isInputError() to render field-level feedback.
- Deploy and verify: Run
astro dev to test locally. Verify that disabled JavaScript still submits forms successfully, and that TypeScript catches schema mismatches during development.