Skip to content
Astro as actions 4 min read

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.

PropertyTypeDescription
error.codestringStatus-like code such as BAD_REQUEST, UNAUTHORIZED, NOT_FOUND
error.messagestringHuman-readable message
error.statusnumberNumeric HTTP-style status
error.fieldsRecord<string, string[]>Present on input validation errors only
isInputError(error)helperNarrows 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 a try/catch) before touching data — 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.code for predictable, stable handling rather than parsing error.message strings.
  • Prefer the default { data, error } shape in plain scripts and .orThrow() only where an error boundary already exists.
Last updated June 14, 2026
Was this helpful?