Calling Actions from the Client
Once an action is defined on the server, Astro generates a fully typed actions object you can import in any client-side script or framework island. Calling an action feels like calling a local async function, but under the hood it serializes the input, performs an RPC to your server, and returns a structured result. Because the entire payload is typed end to end, your editor knows the exact input shape and the exact return value without you writing a single fetch call.
The actions object
Every action you declare in src/actions/index.ts becomes a method on the actions object exported from astro:actions. This same import works in .astro frontmatter, in client <script> blocks, and inside React, Vue, Svelte, or Solid islands. Astro automatically routes the call to the server, so you never hand-write an endpoint URL or a fetch body.
Assume the following server definition:
// src/actions/index.ts
import { defineAction } from "astro:actions";
import { z } from "astro:schema";
export const server = {
likePost: defineAction({
input: z.object({ postId: z.string() }),
handler: async ({ postId }) => {
const likes = await db.post.incrementLikes(postId);
return { likes };
},
}),
};
You can now call actions.likePost from anywhere on the client with full type inference.
Handling the result object
By default an action returns a { data, error } result object rather than throwing. The data property holds your handler’s return value on success; error holds an ActionError (or a validation error) on failure. Exactly one of the two is populated, so destructuring and checking error first is the canonical pattern.
---
// src/components/LikeButton.astro
---
<button id="like" data-post-id="abc123">Like</button>
<script>
import { actions } from "astro:actions";
const button = document.querySelector("#like") as HTMLButtonElement;
button.addEventListener("click", async () => {
const postId = button.dataset.postId!;
const { data, error } = await actions.likePost({ postId });
if (error) {
console.error(error.code, error.message);
return;
}
button.textContent = `Liked (${data.likes})`;
});
</script>
Output:
Liked (42)
Tip: Calling an action in a
<script>ships only that small handler to the browser. The action logic itself stays on the server, preserving Astro’s zero-JS-by-default model — the client never sees your database code.
Throwing instead of returning
If you prefer try/catch flow, append .orThrow() to the call. Instead of returning { data, error }, the action resolves to data directly and throws the ActionError on failure. This is convenient inside framework islands that already use error boundaries.
import { actions } from "astro:actions";
try {
const { likes } = await actions.likePost.orThrow({ postId: "abc123" });
console.log(`Now at ${likes} likes`);
} catch (error) {
console.error("Failed to like:", error);
}
Inspecting structured errors
The error value is rich and machine-readable. Validation failures (when input fails the Zod schema) carry field-level details you can surface in a form; thrown ActionErrors carry a stable code.
| Property | Type | Description |
|---|---|---|
error.code | string | Status-like code such as BAD_REQUEST, UNAUTHORIZED, NOT_FOUND |
error.message | string | Human-readable message |
error.status | number | Numeric HTTP-style status |
error.fields | Record<string, string[]> | Present on input validation errors only |
isInputError(error) | helper | Narrows to a validation error so fields is typed |
import { actions, isInputError } from "astro:actions";
const { data, error } = await actions.likePost({ postId: "" });
if (error) {
if (isInputError(error)) {
// Field-specific messages from the Zod schema
console.log(error.fields.postId); // ["String must contain at least 1 character"]
} else if (error.code === "UNAUTHORIZED") {
window.location.href = "/login";
}
}
Calling actions from a React island
Inside a framework component the import is identical. Pair it with local state to drive a loading and error UI.
// src/components/LikeIsland.tsx
import { useState } from "react";
import { actions } from "astro:actions";
export default function LikeIsland({ postId }: { postId: string }) {
const [likes, setLikes] = useState<number | null>(null);
const [pending, setPending] = useState(false);
async function like() {
setPending(true);
const { data, error } = await actions.likePost({ postId });
setPending(false);
if (!error) setLikes(data.likes);
}
return (
<button onClick={like} disabled={pending}>
{likes === null ? "Like" : `Liked (${likes})`}
</button>
);
}
Render it as an island so the handler runs in the browser:
---
import LikeIsland from "../components/LikeIsland.tsx";
---
<LikeIsland postId="abc123" client:load />
Best Practices
- Always check
error(or use.orThrow()in atry/catch) before touchingdata— never assume success. - Use
isInputError()to narrow validation failures and render field-level messages instead of a generic error. - Keep heavy logic in the server handler; the client call should only marshal input and update the UI.
- Disable buttons or show a pending state while an action is in flight to prevent duplicate submissions.
- Branch on
error.codefor predictable, stable handling rather than parsingerror.messagestrings. - Prefer the default
{ data, error }shape in plain scripts and.orThrow()only where an error boundary already exists.