Skip to content

Actions #898

@bholmesdev

Description

@bholmesdev

Body

Summary

Let's make handling form submissions easy and type-safe both server-side and client-side.

Goals

  • Form actions can accept JSON or FormData payloads.
  • You no longer need boilerplate to safely parse the request body based on the Content-Type.
  • You no longer need boilerplate to retrieve form data values from the request body. The action should be able to enumerate expected input names, and the handler should be able to retrieve these values without a type cast. In other words, no more bangs ! or as string casts as in the example formData.get('expected')! as string.
  • You can call an action using a standard HTML form element with the action property.
  • You can use client JavaScript to call an action using scripts or islands. When doing so, data returned by the action is type-safe without type casting. Note: This should consider form handling in popular component frameworks, like the useActionState() and useFormStatus() hooks in React 19.
  • You can declare actions as an endpoint to call from prerendered / static pages using the hybrid output.

Non-goals

  • A solution to client-side validation. Validation is a major piece to forms with several community libraries to choose from (ex. react-hook-form). Astro may recommend standard HTML attributes for client validation including the required and type properties on an input.
  • A hook to retrieve the return value of an action from Astro frontmatter. Action return values may be accessed from client JavaScript for optimistic updates. Otherwise, we expect actions to persist any state to a database to be retrieved manually from your Astro components.
  • Declaring actions within .astro frontmatter. Frontmatter forms a function closure, which can lead to misuse of variables within an action handler. This challenge is shared by getStaticPaths() and it would be best to avoid repeating this pattern in future APIs.

Background & Motivation

Form submissions are a core building block of the web that Astro has yet to form an opinion on (pun intended).

So far, Astro has been rewarded for waiting on the platform and the Astro community to mature before designing a primitive. By waiting on view transitions, we found a SPA-like routing solution grounded in native APIs. By waiting on libSQL, we found a data storage solution for content sites and apps alike. Now, we've waited on other major frameworks to forge new paths with form actions. This includes Remix actions, SvelteKit actions, React server actions, and more.

At the same time, Astro just launched its database primitive: Astro DB. This is propelling the Astro community from static content to more dynamic use cases:

  • A like button that updates a database counter
  • A comments section that inserts a new comment behind an auth gateway
  • A newsletter form that pings a third party mailing service

To meet our community where it's heading, Astro needs a form submission story.

The problem with existing solutions

Astro presents two solutions to handling form submissions with today's primitives. Though capable, these tools are either too primitive or present unacceptable tradeoffs for common use cases.

JSON API routes

JSON API routes allow developers to handle POST requests and return a JSON response to the client. Astro suggests this approach with a documentation recipe, demonstrating how to create an API route and handle the result from a Preact component.

However, REST endpoints are overly primitive for basic use cases. The developer is left to handle parse errors and API contracts themselves, without type safety on either side. To properly handle all error cases, this grows complex for even the simplest of forms:

REST boilerplate example
// src/api/board/[board]/card/[card].ts
import { db, Card, eq, and, desc } from "astro:db";
import type { APIContext } from "astro";

const POST = async ({params, request}: APIContext) => {
  if (!params.card || !params.board) {
    return new Response(
      JSON.stringify({ success: false, error: "Invalid board or card ID" }),
      { status: 400 }
    );
  }

  if (!request.headers.get("content-type")?.includes("application/x-www-form-urlencoded")) {
    return new Response(
      JSON.stringify({ success: false, error: "Must be a form request" }),
      { status: 400 }
    );
  }

  const body = await request.formData();
  const name = body.get("name");
  const description = body.get("description");
  if (typeof name !== "string" || typeof description !== "string") {
    return new Response(
      JSON.stringify({ success: false, error: "Invalid form data" }),
      { status: 400 }
    );
  }

  // Actual app logic starts here!
  const res = await db
    .update(Card)
    .set({ name, description })
    .where(
      and(eq(Card.boardId, params.board), eq(Card.id, params.card))
    );

  return new Response(
    JSON.stringify({ success: res.rowsAffected > 0 }),
    { status: res.rowsAffected > 0 ? 200 : 404 }
  );
}

The client should also guard against malformed response values. This is accomplished through runtime validation with Zod, or a type cast to the response the client expects. Managing this contract in both places leaves room for types to fall out-of-sync. The manual work of defining and casting types is also added complexity that the Astro docs avoid for beginner use cases.

What's more, there is no guidance to progressively enhance this form. By default, a browser will send the form data to the action field specified on the <form> element, and rerender the page with the action response. This default behavior is important to consider when a user submits a form before client JS has finished parsing, a common concern for poor internet connections.

However, we cannot apply our API route as the action. Since our API route returns JSON, the user would be greeted by a stringified JSON blob rather than the refreshed contents of the page. The developer would need to duplicate this API handler into the page frontmatter to return HTML with the refreshed content. This is added complexity that our docs understandably don't discuss.

View transition forms

View transitions for forms allow developers to handle a submission from Astro frontmatter and re-render the page with a SPA-like refresh.

---
const formData = await Astro.request.formData();
const likes = handleSubmission(formData);
---

<form method="POST">
  <button>Likes {likes}</button>
</form>

This avoids common pitfalls with MPA form submissions, including the "Confirm resubmission?" dialog a user may receive attempting to reload the page. This solution also progressively enhances based on the default form action handler.

However, handling submissions from the page's frontmatter is prohibitive for static sites that cannot afford to server-render every route. It also triggers unnecessary work when client-side update is contained. For example, clicking "Likes" in this example will re-render the blog post and remount all client components without the transition:persist decorator.

Last, the user is left to figure out common needs like optimistic UI updates and loading states. The user can attach event listeners for the view transition lifecycle, though we lack documentation on how to do so from popular client frameworks like React.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Implemented

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions