Actions and Forms

By Syncfusion®, Inc. · Last updated

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.

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:
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.

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.

Browser actions.like(input)

Zod check input schema

handler (server) returns data

data, error

bad input stops here → error

Input is checked first. Only valid input reaches the handler.

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.

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.

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.

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:
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.

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

MistakeWrong ideaCorrect ideaWhat goes wrong
Actions not in serverAny export worksexport const server = { ... }actions.name is not found
Form action without accept: "form"Actions read forms by defaultAdd accept: "form"Input error: expected JSON
Form action on a static pageForms work everywhereUse prerender = falseThe post has nowhere to go
Using data without checking errorCalls always succeedCheck error firstdata is undefined
No permission check in the handlerHidden buttons are safeCheck the session or localsAnyone can run the action
No redirect after a form succeedsThe page is donereturn Astro.redirect(...)A refresh sends the form again

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.