# Actions and Forms

Course: [Astro](https://www.devtutorials.ai/learn/astro) · Section: Server Development
Source: https://www.devtutorials.ai/learn/astro/server/actions-and-forms

> What actions are, defineAction() and the server export, groups, Zod input validation, accept form, isInputError(), calling actions from scripts and HTML forms, getActionResult(), callAction(), ActionError, redirects and securing actions.

## Actions

- In **Endpoints and Server Data**, you wrote endpoints and called them with `fetch()`.
- You had to read the body, check every field and build the response by hand.
- An **action** is a server function you call like a normal function. Astro does the rest:
  - it sends the request,
  - checks the input with a Zod schema,
  - and gives you back `{ data, error }`.
- In this lesson, Asha builds her like button and newsletter form with actions.

**Simple lesson:** an action is a type-safe server function that your page or form can call directly.

### A Real-World Example

- Asha's like button used a hand-written endpoint and `fetch()`.
- Each new feature meant another endpoint, more JSON code and more checks.
- With actions, she writes one function per feature in `src/actions/index.ts`.
- Her browser script calls `actions.like({ slug })`, as if it were local.

Press **▶ Run** and click the button. The **Terminal** shows the action running on the server.

<button id="like">♥ Like</button>
<p id="out"></p>

<script>

    document.getElementById("like")!.addEventListener("click", async () => {
      const { data, error } = await actions.like({ slug: "why-astro" });
      const out = document.getElementById("out")!;
      out.textContent = error ? "Error: " + error.message : "Likes: " + data.count;
    });
</script>
`,
  }}
/>

What happened:

- `defineAction()` described the action: its input and its handler.
- The browser script imported `actions` from `astro:actions` and called `actions.like()`.
- The handler ran on the server. Its return value came back as `data`.

### What You Will Learn

In this lesson, you will learn:

1. How to define actions and group them.
2. How to check input with Zod, for JSON and for forms.
3. How to show input errors next to form fields.
4. How to call actions from scripts, forms and the server.
5. How to fail on purpose with `ActionError`.
6. How to redirect after success.
7. How to keep actions secure.

## Actions Basics

### Defining an Action with defineAction()

- `defineAction({ input, handler })` makes one action.
- `input` = a Zod schema for what the caller must send.
- `handler(input, context)` = the server code. Its return value is the result.

### The server Export

- **[TS]** Actions live in `src/actions/index.ts`. **[JS]** In `src/actions/index.js`.
- Export them in an object called `server`.
- The key is the action's name: `server.like` becomes `actions.like`.

### Organising Actions in Groups

- Nest objects to group related actions:

```ts
export const server = {
  blog: { like: defineAction({ ... }), comment: defineAction({ ... }) },
  newsletter: { subscribe: defineAction({ ... }) },
};
```

- Call them with dots: `actions.blog.like(...)`, `actions.newsletter.subscribe(...)`.

Use an action when your own pages call the server. Use an endpoint when other websites or apps need a plain URL that returns JSON.

<figure className="ast-fig ngc-fig">
<svg viewBox="0 0 660 200" role="img" aria-labelledby="fig-act" style={{ width: "100%", maxWidth: 660, height: "auto" }}>
  <title id="fig-act">Animation: the browser calls actions.like. The input passes the Zod check, the handler runs on the server, and data or an error comes back.</title>
  <defs><marker id="ac2-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10 z" style={{ fill: "var(--ngc-slate)" }} /></marker></defs>
  <rect x="16" y="60" width="150" height="70" rx="10" style={{ fill: "var(--ngc-blue-soft)", stroke: "var(--ngc-blue)" }} />
  <text x="91" y="90" textAnchor="middle" style={{ fill: "var(--ngc-blue)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>Browser</text>
  <text x="91" y="110" textAnchor="middle" style={{ fill: "var(--ngc-blue)", fontFamily: "var(--mono)", fontSize: 11 }}>actions.like(input)</text>
  <rect x="236" y="60" width="140" height="70" rx="10" style={{ fill: "var(--ngc-orange)" }} />
  <text x="306" y="90" textAnchor="middle" style={{ fill: "#fff", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>Zod check</text>
  <text x="306" y="110" textAnchor="middle" style={{ fill: "#fff", fontFamily: "var(--sans)", fontSize: 11 }}>input schema</text>
  <rect x="446" y="60" width="200" height="70" rx="10" style={{ fill: "var(--ngc-purple-soft)", stroke: "var(--ngc-purple)" }} />
  <text x="546" y="90" textAnchor="middle" style={{ fill: "var(--ngc-purple)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>handler (server)</text>
  <text x="546" y="110" textAnchor="middle" style={{ fill: "var(--ngc-purple)", fontFamily: "var(--mono)", fontSize: 11 }}>returns data</text>
  <path d="M166 85 H232" style={{ stroke: "var(--ngc-slate)", strokeWidth: 2, fill: "none" }} markerEnd="url(#ac2-arrow)" />
  <path d="M376 85 H442" style={{ stroke: "var(--ngc-slate)", strokeWidth: 2, fill: "none" }} markerEnd="url(#ac2-arrow)" />
  <path d="M546 130 V170 H91 V134" style={{ stroke: "var(--ngc-green)", strokeWidth: 2, fill: "none" }} markerEnd="url(#ac2-arrow)" />
  <text x="320" y="164" textAnchor="middle" style={{ fill: "var(--ngc-green)", fontFamily: "var(--mono)", fontSize: 12, fontWeight: 700 }}>{"{"} data, error {"}"}</text>
  <circle r="6" style={{ fill: "var(--ngc-blue)" }}><animateMotion dur="4s" repeatCount="indefinite" path="M166 85 H546 V170 H91 V130" /></circle>
  <text x="306" y="44" textAnchor="middle" style={{ fill: "var(--ngc-red)", fontFamily: "var(--sans)", fontSize: 11 }}>bad input stops here → error</text>
</svg>
<figcaption>Input is checked first. Only valid input reaches the handler.</figcaption>
</figure>

**Simple lesson:** actions live in `src/actions/index.ts`, inside `export const server = { ... }`.

## Input Validation

### input with Zod

- The `input` schema is checked **before** the handler runs.
- Bad input never reaches your code. The caller gets an error instead.
- Use the same Zod tools as in **Content Collections**: `z.string().min(2)`, `z.number().int()`, `z.string().email()`.

### Displaying Input Errors with isInputError()

- `isInputError(error)` is `true` when the input failed the schema.
- Then `error.fields` lists the messages per field: `error.fields.name` is an array of text.
- Show each message next to its field.

### [TS] Type Inference from Inputs

- The handler's input type comes from the schema. `name` is a `string`, `age` a `number`.
- The caller's argument is checked by your editor too: `actions.like({ slugg: "x" })` is underlined.
- **[JS]** The runtime checks are the same. You just do not get editor warnings.

Press **▶ Run**. Try a short name and a bad email, then fix them.

<p><input id="name" value="A" /> <small id="name-err"></small></p>
<p><input id="email" value="not-an-email" /> <small id="email-err"></small></p>
<button id="join">Join</button>
<p id="out"></p>

<script>

    const value = (id: string) => (document.getElementById(id) as HTMLInputElement).value;
    const show = (id: string, text: string) => (document.getElementById(id)!.textContent = text);

    document.getElementById("join")!.addEventListener("click", async () => {
      const { data, error } = await actions.newsletter.subscribe({
        name: value("name"),
        email: value("email"),
      });
      show("name-err", "");
      show("email-err", "");
      if (isInputError(error)) {
        show("name-err", error.fields.name?.join(", ") ?? "");
        show("email-err", error.fields.email?.join(", ") ?? "");
        show("out", "Please fix the fields above.");
        return;
      }
      show("out", data ?? "");
    });
</script>
`,
  }}
/>

**Simple lesson:** the Zod schema checks input first. `isInputError()` and `error.fields` let you show each problem.

## Calling Actions from Forms

### Validating Form Data with accept: "form"

- HTML forms send **form data**, not JSON.
- Add `accept: "form"` to the action. Astro reads the fields by their `name`, then checks them with the schema.
- Numbers and checkboxes are converted for you when the schema says `z.number()` or `z.boolean()`.

### Calling Actions from an HTML Form

- Set the form's `action` to the action: `<form method="POST" action={actions.newsletter.subscribe}>`.
- The page must be **on demand** (`prerender = false`), because the server handles the post.
- This works even if JavaScript is turned off in the browser.

### Reading Results with Astro.getActionResult()

- After the form posts, the page renders again.
- `Astro.getActionResult(actions.newsletter.subscribe)` returns `{ data, error }`, or `undefined` if the form was not sent.

Press **▶ Run**. Send the form empty first, then fill it in.

{result && !result.error && <p class="ok">Subscribed: {result.data}</p>}
<form method="POST" action={actions.join}>
    <label>Email <input name="email" /></label>
    {fieldErrors.email && <small class="err">{fieldErrors.email.join(", ")}</small>}
    <label><input type="checkbox" name="weekly" /> Send weekly</label>
    <button>Join</button>
</form>
`,
  }}
/>

A form action needs no `<script>`. The browser sends the form, the server runs the action, and the page shows the result. Pages like this keep working on slow phones or when JavaScript fails.

**Simple lesson:** `accept: "form"` plus `<form method="POST" action={actions.x}>`, then read the result with `Astro.getActionResult()`.

## More Ways to Call and Fail

### Handling data and error

- Every call returns `{ data, error }`. Exactly one of them is set.
- Always check `error` before you use `data`.

### .orThrow()

- `await actions.like.orThrow(input)` returns `data` directly, and **throws** if there is an error.
- Use it when an error should stop your code, such as in a `try`/`catch`.

### Calling Actions from Components and Endpoints

- On the server, `await Astro.callAction(actions.like, { slug })` runs an action without a request.
- Useful to reuse the same logic in a page script.

### ActionError and Error Codes

- Throw `new ActionError({ code, message })` in a handler to fail on purpose.
- Codes include `"BAD_REQUEST"` (400), `"UNAUTHORIZED"` (401), `"FORBIDDEN"` (403), `"NOT_FOUND"` (404) and `"TOO_MANY_REQUESTS"` (429).
- The caller gets `error.code` and `error.message`.

### Redirecting After Success

- After a form action succeeds, send the visitor somewhere: `if (result && !result.error) return Astro.redirect("/thanks");`.
- This stops a page refresh from sending the form a second time.

Press **▶ Run**. Try the codes `astro` and `wrong`. Then use the form, which redirects on success.

{result?.error && <p class="err">{result.error.code}: {result.error.message}</p>}
<form method="POST" action={actions.redeem}>
    <input name="code" placeholder="Try astro" />
    <button>Redeem</button>
</form>
`,
    "src/pages/thanks.astro": `---
---

<h1>Thank you!</h1>
<p>You won: {Astro.url.searchParams.get("prize")}</p>
`,
  }}
/>

**Simple lesson:** check `error`, throw `ActionError` to fail on purpose, and redirect after a form succeeds.

## Securing Actions

### Authorizing Users in the Handler

- An action is a public address. Anyone can call it, even without your page.
- Check who is calling **inside the handler**, using `context.session` or `context.locals`.
- If they are not allowed, `throw new ActionError({ code: "UNAUTHORIZED" })`.

### Gating Actions in Middleware with getActionContext()

- For rules that cover many actions, check in middleware.
- `getActionContext(context)` from `astro:actions` tells you which action is being called:

```ts
import { defineMiddleware } from "astro:middleware";
import { getActionContext } from "astro:actions";

export const onRequest = defineMiddleware(async (context, next) => {
  const { action } = getActionContext(context);
  if (action?.name.startsWith("admin.") && !context.locals.user?.isAdmin) {
    return new Response("Forbidden", { status: 403 });
  }
  return next();
});
```

Press **▶ Run**. Try to delete a comment before and after "logging in". The handler checks the session.

<button id="delete">Delete comment 7</button>
<button id="login">Log in</button>
<p id="out"></p>

<script>
    const out = document.getElementById("out")!;

    document.getElementById("delete")!.addEventListener("click", async () => {
      const { data, error } = await actions.deleteComment({ id: 7 });
      out.textContent = error ? error.code + ": " + error.message : data;
    });
    document.getElementById("login")!.addEventListener("click", async () => {
      const { data } = await actions.login();
      out.textContent = data;
    });
</script>
`,
  }}
/>

Hiding a button is not security. Anyone can call an action with a few lines of code. Always check permissions inside the handler or in middleware.

**Simple lesson:** treat every action as public. Check the user in the handler or in middleware.

## TL;DR

1. **Action** = a server function you call like a local function. `defineAction({ input, handler })`.
2. **Where** = `src/actions/index.ts`, in `export const server = { ... }`. Nest objects for groups.
3. **input** = a Zod schema, checked before the handler runs.
4. **Call from a script** = `const { data, error } = await actions.name(input)`.
5. **isInputError(error)** = schema failure. `error.fields` = messages per field.
6. **Forms** = `accept: "form"`, `<form method="POST" action={actions.name}>`, on-demand page.
7. **Read the form result** = `Astro.getActionResult(actions.name)`.
8. **.orThrow()** = data or a thrown error. **Astro.callAction()** = run on the server.
9. **ActionError** = fail on purpose with a code. Redirect after form success.
10. **Security** = check the user in the handler or with `getActionContext()` in middleware.

## Common mistakes

<div className="tscroll">

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| Actions not in `server` | Any export works | `export const server = { ... }` | `actions.name` is not found |
| Form action without `accept: "form"` | Actions read forms by default | Add `accept: "form"` | Input error: expected JSON |
| Form action on a static page | Forms work everywhere | Use `prerender = false` | The post has nowhere to go |
| Using `data` without checking `error` | Calls always succeed | Check `error` first | `data` is `undefined` |
| No permission check in the handler | Hidden buttons are safe | Check the session or locals | Anyone can run the action |
| No redirect after a form succeeds | The page is done | `return Astro.redirect(...)` | A refresh sends the form again |

</div>

## Check Yourself

## Exercises

## What to Remember

- Actions are server functions in `src/actions/index.ts`, exported in `server`.
- The Zod `input` schema checks every call before the handler runs.
- Call them from scripts (`{ data, error }`), from forms (`accept: "form"`) or on the server.
- Throw `ActionError` to fail on purpose, and redirect after a form succeeds.
- Every action is public: check the user inside it.

Next: **Authentication**, where Asha builds a real login with sessions, actions and middleware.
