-
Notifications
You must be signed in to change notification settings - Fork 30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Actions #898
Comments
Moved this from the Stage 1, posted this right before it moved to Stage 2: I am not super familiar with the subject at hand (I think in the past 10 years, I've only written a One thing I'll just mention is that I saw people float the idea of having two files with the same names but a different extension (like |
@Princesseuh I agree with this! During goal setting, we also found a more fundamental issue following SvelteKit's spec for look-behind files: all pages with a A common example: a blog with a like button. Each post in your blog would include the like button, and submitting a "click" would trigger a form action to update your database counter. Using the SvelteKit convention, you would duplicate your // src/pages/[...slug].ts
export const actions = {
like: defineAction(...),
} However, this now means You might think "well, form actions do require server handling. Maybe that refactor is justified." This is fair! However, there are alternative APIs we can explore to keep refactors to a minimum for the developer. One solution would be to keep action declarations to standalone endpoint files, like a |
Where should actions live?I want to outline a few places where actions may be defined:
src/pages/blog/api.ts
export const actions = {
like: defineAction(...),
} To call these actions from a
Right now, I actually lean towards (4). I admittedly wanted to like (3) in this list, since I value colocation with my pages. However, I quickly found myself centralizing actions to a I also realized colocation isn't self-explanatory. We want colocation... with what? After working on Astro Studio, I realized we didn't use colocation with presentational UI in // src/procedures/trpc.ts
app({
user: userRouter, // from ./users.ts
github: githubRouter, // from ./github.ts
project: projectRouter, // from ./projects/.ts
});
// in your app
trpc.user.auth()
trpc.github.createTemplate()
trpc.project.create() We could create a similar organization with actions/
user.ts
github.ts
project.ts
index.ts import { userActions } from './user';
import { githubActions } from './github';
import { projectActions } from './project';
export const actions = {
user: userActions,
github: githubActions,
project: projectActions,
}
// in your app
actions.user.auth()
actions.github.createTemplate()
actions.project.create() This pattern mirrors REST-ful APIs as well, which I hope feels intuitive for existing Astro users. I glossed over specifics on calling these actions from a |
I would not recommend this. POST requests with form data are considered "simple requests" and ignored by browsers' same origin policy. Even if Astro implements a CSRF protection by default, some devs are going to disable it to enable cross-origin and cross-site requests and not implement CSRF protection since they assume their actions only accept JSON (which is subjected to SOP). |
Thanks for sharing that nuance @pilcrowonpaper. I know Astro recently added built-in CSRF protection across requests, but I see what you mean about the payloads having different behavior. It sounds like we shouldn't implicitly let you pass form data or json to the same action. How would you feel if the action explicitly defined the payload it is willing to accept? Assuming a Zod setup for validation, it may look like the following: const like = defineAction({
input: formData(), // accepts form data
handler: () => {}
});
const comment = defineAction({
input: z.object(), // accepts json
handler: () => {}
}); |
TL;DR: Make forData opt-in by exporting additional utilities or mapping
@bholmesdev I just recently spend some effort using @conform-to/zod's Safely parsing form data, and maybe validating it against a schema sounds like a good goal to me. Here's how I currently use forms in Astro: // admin/discounts/create.astro
---
import { createForm } from 'lib/forms.ts'
const discountSchema = z.object({
description: z.string().optional().default(""),
limit: z.number().int().min(1).default(1),
code: z.string().optional(),
})
const { data, fields, isValid, errors } = await createForm(discountSchema, Astro.request)
// createForm builds fields with required attributes from schema, checks for `request.method=='POST'
// and parses with Zod to data if valid, or errors otherwise
if(isValid) {
try { await storeOnServer(data) } catch {}
}
---
<form method="post">
<label>Code</label>
<input {...fields.code} /> /* type="text" name="code" */
<small>{errors.code?.message}</small>
<label>Limit</label>
<input {...fields.limit} /> /* type="number" name="limit" min="1" */
<small>{errors.limit?.message}</small>
<label>Description</label>
<textarea {...fields.description} /> /* name="description" */
<small>{errors.description?.message}</small>
<button type="submit">Create</button>
</form> Exported actionThe below is very much my interopretation/stab at an API I personally would prefer using, and not so much what I think makes sense from a technical perspective. I think locating actions inside an actions folder is fine, especially if the resulting endpoints don't necessarily match the names or folder structure. That would allow collocation multiple related actions togethers // src/actions/cart.ts
export const sizes = ["S", "M", "L", "XL"];
const cartItem = z.object({
sku: z.string(),
size: z.enum(sizes),
amount: z.number()
})
export addToCart = createAction({
schema: cartItem,
handler: (item: z.infer<typeof cartItem>) => addToCart(item)
})
export updateCartItem = createAction({
schema: cartItem,
handler: (item: z.infer<typeof cartItem>) => updateCart(item)
})
// etc
Usage in formsOn the consumer side, I would use it like the following, perhaps: // pages/store/[product].astro
import { addToCart } from '../../actions/cart.ts'
const product = Astro.props
const { form, inputs } = addToCart
---
<form {...form.attrs}> /* action="/_actions/cart/addToCart" method="post" */
<label>Amount</label>
<input {...form.inputs.amount.attrs } />
<select {...form.inputs.size.attrs }>
{form.inputs.size.options.map(size => <option value={size}>{size}</option>)}
</select>
<input {...form.inputs.sku.attrs} type="hidden" value={product.sku} />
<button type="submit">Add to cart</button>
</form> If I would like to use the action as a full page form submission instead, maybe an other API could be something like: Which creates the above attributes and a form handler on this specific route. It would submit to the action/call its handler, with validated formData and additionally output errors/success state. // pages/store/[product].astro
import { addToCart } from '../../actions/cart.ts'
import { formAction } from 'astro:actions'
const product = Astro.props
const { form, inputs, success, errors } = await formAction(addToCart, Astro.request)
---
<form {...form.attrs}> /* action="" method="post" */
{success && <p>Added to cart</p>}
<label>Amount</label>
<input {...form.inputs.amount.attrs } />
{errors.amount && <small>{error.amount.message}</small>}
<select {...form.inputs.size.attrs }>
{form.inputs.size.options.map(size => <option value={size}>{size}</option>)}
</select>
{errors.size && <small>{error.size.message}</small>}
<input {...form.inputs.sku.attrs} type="hidden" value={product.sku} />
<button type="submit">Add to cart</button>
</form> Usage inside Framework ComponentsYou won't touch the exported form property at all and instead directly use the schema, url etc // components/addToCartButton.tsx
import { addToCart, sizes } from '../../actions/cart.ts'
export const AddToCartButton = (props) => {
const [amount, setAmount] = createSignal<z.infer<typeof addToCart.schema>['amount']>(1)
const [size, setSize] = createSignal<z.infer<typeof addToCart.schema>['size']>(sizes[0])
const [success, setSuccess] = createSignal(false)
const [errors, setErrors] = createSignal(null)
const submit = () => {
try {
await someHttpLib.post(addToCart.url, { amount: amount(), size: size(), sku: props.sku })
setSuccess(true)
} catch (e) {
setErrors(e)
}
}
return (<div>
<NumberField value={amount} onChange={setAmount} />
<Dropdown options={size} selected={size} onSelect={setSize} />
<Button onClick={submit} />
</div>)
} Could even take it up a notch and let submission and handling of the server action result be an exported function // components/addToCartButton.tsx
import { addToCart, sizes } from '../../actions/cart.ts'
export const AddToCartButton = (props) => {
const [amount, setAmount] = createSignal<z.infer<typeof addToCart.schema>['amount']>(1)
const [size, setSize] = createSignal<z.infer<typeof addToCart.schema>['size']>(sizes[0])
const [success, setSuccess] = createSignal(false)
const [errors, setErrors] = createSignal(null)
const submit = () => {
const { success, errors } = await addToCart.submit({ amount: amount(), size: size(), sku: props.sku })
setSuccess(success)
setErrors(errors)
}
return (<div>
<NumberField value={amount} onChange={setAmount} />
<Dropdown options={size} selected={size} onSelect={setSize} />
<Button onClick={submit} />
</div>)
} |
Thanks for that @robertvanhoesel! Good to see thorough overviews like this. I think we're pretty aligned on how we want form actions to feel. Pulling out a few pieces here:
|
I think if actions export enough data/schemas/utilities the very concrete implementation I showed above could be more of a library implementing the actions API rather than something that should be packed by default. Could still be part of the core offering but ultimately it starts becoming prescriptive in how to implement actions. In the end this is a pure DOM spec implementation to have some client side validation based on an action schema. It makes sense to live as
I have not considered a directive. Would I assume you mean something like: <input action:attributes={action.schema.myProperty} /> I'm not sure if I'm a fan, but perhaps that's because directives are sparsely used in Astro at the moment. I have considered building it into a component, but my main argument against it is that it would prevent you from using UI Framework components which enhance standard HTML Inputs with validation states or UX. I want to be able tp spread the validation attributes and field name onto a framework component, which has my preference over using some custom Is the below syntax possible in Astro (maybe it already is!), a component that can take a function as contents/default slot where the first param is the generated inputs/validation?---
import { Form } from 'astro:actions'
import { postComment } from '../actions/comments'
---
<Form action={postComment}>{(inputs) => (
<div>
<input {...inputs.name.attrs} />
<textarea {inputs.content.attrs} />
</div>
)}
</Form> Beyond this DOM topic:
Partial Page Action / Multiple FormsMy biggest frustration currently with native forms and handling forms in Astro is whenever you start having more than one form/action on a page. I currently make sure components that include a form, and can be on the same page as other form components, will check for some unique I even have a helper in my createForm for creating that scope on the submit and then also validating the scope value is set whenever a POST request on that route is triggered. const { fields, submitButton } = await createForm(schema, Astro.request, { scope: 'someIdentifier' })
---
<form>
...
<button {...submitButton} >
</form> I was initially excited for (Form) Actions in Astro because I hoped it would offer a fairly native solution to this without the need of client side UI components. So, that makes me wonder, can we adopt a baseline HTMX feature set for this. Combining Partial Route Components (so they can be routed to) with ViewTransitions. // pages/partials/like.astro
---
import { likeAction } from '../../actions/like.ts'
export const partial = true;
interface Props {
count: number
slug: string
}
let count = Astro.props.count
if(Astro.request.method==="POST") {
try {
count = await likeAction(Astro.request.formData()) // count++
} catch (e) { .... }
}
---
<form transition:replace action="partials/like" method="post">
{count} likes
<button type="submit"> Like</button>
<input type="hidden" name="slug" value={Astro.props.slug} />
</form> So we could do something like the below on routes that have ViewTransitions enabled. // pages/blogs/[slug].astro
---
import Like from '../../partials/like.astro'
const blog = Astro.props
---
<h1>{blog.title}</h1>
<LikeCount slug={blog.slug} count={blog.likes} /> If not the above, I'm really puzzled as to why to support formData out of the box in actions. In that case I'm a fan of Astro because I can sprinkle in frameworks and libraries where needed. It would be super amazing if we could have basic self replacing forms out of the box. |
Appreciate the input @robertvanhoesel. I'm hearing a few pieces in your feedback:
|
@bholmesdev your last bullet sounds a bit htmx-ish. |
Just to spit ball & throw it out there: I think the majority of the time a JSON payload is all anyone will ever need. The one / main use cases I can think of for when The main example being a user account settings page where the user can upload an avatar / image. The options in this scenario in my mind would be:
The other main scenario I could possibly wonder when |
Alright, we've been iterating on form actions with a lot through feedback from core, maintainers, and the general community. It seems the thoughts that resonated most were:
I'm excited to share a demo video for a proof-of-concept. We're already reconsidering some ideas noted here, namely removing the need for an https://www.loom.com/share/81a46fa487b64ee98cb4954680f3646e?sid=717eb237-4dd8-41ce-8612-1274b471c2ca |
Good feedback @NuroDev! I agree JSON is the best default, and we'll likely lead with that in our documentation examples. Still, the That's also a good callout on file uploads. It sounds like the tRPC team has encoded file blobs to JSON strings with reasonable success, but it's probably not the approach for everyone. At the very least, it would be nice to access the raw request body from a context variable if you need to go deep parsing inputs. |
Wait, how do you implement progressive enhancement with JSON? Re: input validation - I don't really see the benefit of integrating it into the core of form actions. Why can't it be a wrapper over |
As a counter point to this: Astro already offers input validation for stuff like content collections out of the box with Though that did pose me the question: If someone doesn't want any kind of validation, or wishes to use a different validation library (Valibot, Typebox, etc) would the 1st parameter of the handler just return the un-validated request body I assume? |
Alright, thanks again for the quality input everyone. We've finally hit a point that we can draft an RFC. It includes complete docs of all expected features, and describes any drawbacks or alternatives we've discussed here. Give it a read when you get the chance. Open to feedback as always. |
I took a brief look at #912 and it feels a tad overcomplicated and restrictive. I don't want to use The ideal API would allow defining functions in the same astro file as the form: ---
const doStuff = async (formData) => {
// do stuff with formData
}
---
<form method="POST" action={doStuff}>…</form> I see that this is listed as a non-goal, which is a bit unfortunate. What is the main problem with this? (i.e. what is meant by "misuse of variables"?) Today we can already handle form submissions in frontmatter, so this kinda feels natural to me? (at least when using SSR) As a compromise, it would be nice if actions were regular functions and allowed to be exported from anywhere (not just in // in src/wherever.js
export const doStuff = async (formData) => {
// do stuff with formData
} and imported anywhere, probably with import attributes: ---
import { doStuff } from "../wherever.js" with { type: "action" };
---
<form method="POST" action={doStuff}>…</form> The RFC says the main argument against this syntax is that users might forget to set the import attribute. (I feel like users deserve more credit than that, but anyway…) Maybe there should be runtime restriction around where the action is invoked from. e.g. Exampleexport const doStuff = async () => {}; import { doStuff } from "./doStuff" with { type: "action" };
import { invokeAction } from "astro:actions";
const handleFormSubmit = async (e) => {
e.preventDefault();
// throws if not used in .astro file
// doStuff();
// must wrap in invokeAction
const { stuff } = await invokeAction(doStuff)();
}
<form onSubmit={handleFormSubmit}…</form> Edit: Is it possible to implement it such that it throws if imported without the attribute? I feel like it should be possible using a helper at the place where the action is defined (rather than at the call-site). import { defineAction } from "astro:actions";
export const doStuff = defineAction(async () => {}); import { doStuff } from "./doStuff";
await doStuff(); // ❌ throws import { doStuff } from "./doStuff" with { type: "action" };
await doStuff(); // 👍 all good |
Hey @mayank99! Appreciate the detailed input here. It sounds like you see a bit too much overhead with I'll also admit export const server = {
comment: async (formData: FormData) => {...}
} Once we have an experimental release, it may be worth trying this alongside It also seems like you value colocation of actions within your Astro components. We agree, this seems intuitive. However, we had a few qualms with actions inside Astro frontmatter beyond what was listed in the non-goal:
Your import attributes idea would solve this issue.
|
@bholmesdev I appreciate you taking the time to dive deeper into my feedback! I enjoy this kind of nuanced discussion 💜
Normally I extract the fields like this: const { postId, title, body } = Object.fromEntries(formData); and validate them in varying ways: Manuallyif (typeof postId !== 'string') throw "Expected postId to be a string";
// … Using
|
@mayank99 Building on your comment on import attributes, you're right that they're not quite standardized yet, another possible, less elegant, but ready solution might be using import query params, e.g.
E.g. // src/components/Like.tsx
import { actions } from "~/actions/index.ts?action";
import { useState } from "preact/hooks";
export function Like({ postId }: { postId: string }) {
const [likes, setLikes] = useState(0);
return (
<button
onClick={async () => {
const newLikes = await actions.like({ postId });
setLikes(newLikes);
}}
>
{likes} likes
</button>
);
} |
Thanks for the input @mayank99 and @okikio! Let me speak to a few bits of feedback I'm hearing: First, it sounds like there's desire to handle validation yourself without an handler: (formData) => {
const { postId, title, body } = PostSchema.parse(Object.fromEntries(formData));
} I spent some time prototyping this, since I did like the simplified onboarding experience. However, there's a few problems with the approach:
const result = await actions.myAction.safe(formData);
if (isInputError(result.error)) {
result.error.fields.postId // autocompletion!
} In the end, it felt like a first-party action: defineAction({
accept: 'form',
handler: (formData) => {
formData.get('property')
}
}) Now, about the First, I'm pretty confident we can avoid an intermittent issues with virtual module types using this model (see astro:db). Types are inferred directly from the It also seems like colocation with individual components is valuable to y'all. I understand that. Still, I want to offer some thoughts: First, frontmatter closures are dangerous for reasons not discussed in the above examples. I can't forget a discussion with the Clerk CEO about an authentication issue their users were seeing. They expected the following to gate their action correctly: function Page() {
const user = auth();
function login() {
"use server";
if (user.session === 'expected') { /* do something */ }
}
return (<></>);
} Not only did this not check the user object from the action, but it encoded the user as a hidden form input to "resume" that state on the server. This is because components do not rerun when an action is called; only the action does. Unless Astro wants to adopt HTML diffing over client-side calls, we would have the same problem. Putting closures aside, there is a world using import attributes from a separate file. But there are a few troubles that make this difficult:
I'll also address Okikio's suggestion for a In the end, an Hope this makes sense! Our goal was to entertain every possible direction before landing on a release, so this is all appreciated. |
First of all, I love that you are adding this feature! I use SvelteKit in my job and the Superforms library has been a gamechanger so I am glad that Astro is adding something similar. Fun fact about the above conversation: the Superforms library just had a major rewrite to allow for more custom validation but that is besides my point haha. I was playing around with the feature on a project that I want to use this. On this project I need to set a Anyway, thanks for all this great work! |
Hey @cbasje! Glad you're enjoying the feature. You're right that the |
You can't, that's why actions being able to handle FormData is so important. Accessibility requirements mean that many large organisations and government departments mandate that their public facing (and they strongly recommend that their internal) apps and sites are fully functional without JS. Try using an SPA with screen reader software, and you'll quickly see why this isn't just about catering for people who've decided for whatever reason to disable JS in their browser. |
@ciw1973 @pilcrowonpaper Adam Silver has a great blog post on this topic: |
@bholmesdev Finally got to play around with Actions today. Excellent job on the implementation and API, error handling and progressive enhancement (I like how One question I have: I can export an action from a route export const myAction = () => defineAction({ ... } |
Glad you like how actions are working @robertvanhoesel! And uh... I'm very surprised this works actually. Can you give a more complete example? 😅 |
Stage 3 RFC available in #912 |
@bholmesdev See below, maybe it works because the action is imported in the <script> element vs the frontmatter? Would you say it's better to avoid this pattern?
---
import Button from "@components/ui/Button.astro";
import { UserPermissions, formatRole, userHasPermission } from "@services/users";
import { ActionError, defineAction, z } from "astro:actions";
import { Trash, UserPlus, Users } from "lucide-static";
const { api, session } = Astro.locals;
const users = await api.listUsers();
const canManageTeam = await userHasPermission(Astro, UserPermissions.manage.team);
export const removeUserAction = () =>
defineAction({
input: z.object({
orgId: z.number(),
userId: z.number(),
}),
handler: async ({ orgId, userId }, ctx) => {
if (userId === ctx.locals.session.user?.id)
throw new ActionError({ message: "You cannot remove yourself from the organization", code: "BAD_REQUEST" });
if (!(await userHasPermission(ctx, UserPermissions.manage.team)))
throw new ActionError({ message: "You do not have permission to perform this action", code: "UNAUTHORIZED" });
// .... api call
return true;
},
});
---
<table>
{
users.map(async ({ user, role }) => (
<tr>
<td>{user.name}</td>
{canManageTeam && (
<td>
{user.id != session.user?.id && (
<Button
size="sm"
isDanger
data-remove-user={user.id}
data-org={game.organization_id}
icon={Trash}
/>
)}
</td>
)}
</tr>
))
}
</table>
<script>
import { actions } from "astro:actions";
document.addEventListener("astro:page-load", () => {
document.querySelectorAll<HTMLButtonElement>("[data-remove-user]").forEach((button) => {
button.addEventListener("click", async () => {
const remove = window.confirm("Are you sure you want to remove this user?");
if (!remove) return;
const result = await actions.manage.removeUserfromOrg.safe({
userId: +button.dataset.removeUser!,
orgId: +button.dataset.org!,
});
if (result.data) button.closest("tr, .table-row")?.remove();
else window.alert(result.error?.message);
});
});
});
</script>
import { removeUserAction } from "src/pages/manage/[gameSlug]/config/users/index.astro";
export const server = {
// every actiong listed under 'manage' will be prefixed with '/_actions/manage.' and ensures the middleware matches the route and authenticates the user
manage: {
removeUserfromOrg: removeUserAction()
}
}; |
@robertvanhoesel Ah ha, I didn't realize you were importing the action back into your I'll take that as a request for colocation. |
@bholmesdev if Repro: https://github.com/arihantverma/astro-actions-zod-union-repro |
@arihantverma ah ha, I don't think we have handling for union types in our inference code. It's more complex to traverse a Zod union to match FormData fields correctly, so we haven't implemented yet. Can you share your use case? |
Regarding non-JSX/React development (with Svelte 5RC in this case)... I'd like to be able to expose a property on the Svelte component that represents the return types for the action.
While this code works:
I wonder if there's a less verbose way of doing the same thing that has been provided or could be provided? |
Ah okay, got it. Amm… so I was playing around with this use case:
If the image is big, I break it in chunks. If the image is big and there are > 1 number of chunks, I don't want to send the caption and alt data again. So for a big image, first action call sends first image chunk, caption and alt, the subsequent action calls send only remaining chunks without alt and caption. So I was trying out a union for the types // first chunk
type FirstChunk = {
type: "first-chunk"
image: File,
alt: string
caption?: string
}
type RestChunk = {
type: "rest-chunk"
image: File
}
type ActionFormData = FirstChunk | RestChunk Since zod union didn't convert FormData in JavaScript object, I ended up writing two actions and handling the common handling in a reusable function easily. Unless I'm missing something, is this a valid use case? |
Great point @wiredprairie. I bet we could expose a utility type for this, like: |
Got it @arihantverma. That use case definitely makes sense! I agree creating two actions would be the safest workaround. To explain a bit how our internals work: we crawl through your Zod schema to decide how the incoming That said, I bet we could support Zod's |
@bholmesdev oh yes |
I have been playing with actions a bit and I appreciate that it can replace the need to have a separate Express/Fastify/Hono/etc API with Astro. I get that in general server actions have been conceived as being mutation focused... However in terms of code organization, isn't it nice to have all your CRUD actions (i.e. including "read") maintained in one place as would be the case of a typical/classic backend from Express to Fastify to Hono? Is "read"/"get" a use-case that has been considered in any detail? The typing doesn't seem as nice if one is using actions to fetch data and the interface both with and without I would love to view astro actions as the potential replacement for a separate backend API. |
In terms of influence / inspiration for some of the RPC like features I would like to share the lesser known ts-rest which is an RPC-like setup over JSON-REST (using JSON/REST makes API's compatible everywhere not some stack-specific protocol which is important for business and may use-cases). See https://ts-rest.com/docs/intro or a real example from my repo https://github.com/firxworx/ts-rest-workspace. There are a few comparisons to TRPC in the various discussions on actions but I'm not sure this is always the best source of inspiration because of how completely "proprietary" the protocol can be at times. One part that I'd like to tickle y'all brains with is that 'contract' concept (between client and server) is entrenched as a central concept and how its used to "guarantee" (thanks to zod validations) how requests + responses are typed. It also provides really nice client DX with an RPC-like client including one compatible with react-query. The availability of clients, the option for custom clients, plus well-documented ability to go without anything and use curl or postman or write your own fetch calls from scratch provides a lot of options for developers. I view actions in a similar sort of light where its like a contract definition of "I get this input and will only respond this or that output" and its really cool how it even includes the possible error responses and statuses in that contract. |
A quick piece of feedback: I don't think I found it confusing and interpreted that as "this is a special I get it if Astro exports its own |
@bholmesdev sorry not sure where to put these, tagging so at least someone who knows sees them. New piece of feedback after another evening playing with actions: The Proposed change/fix: Example Use-Case An example use-case which we already see in the wild is using react-query e.g. By not choosing In the The Discussion This is one specific use-case example. It is reasonable to assume that a dev may use a try/catch block with actions and as a result all errors received by the Therefore any widely applicable / generally useful type guard should accept input type If you want to keep the typing of Minor Point The name of the For maintainability and devX type guards should be consistently named for what they check e.g. Therefore the type guard should be named Perhaps more minor I would also suggest ditching the |
Body
Summary
Let's make handling form submissions easy and type-safe both server-side and client-side.
Goals
Content-Type
.!
oras string
casts as in the exampleformData.get('expected')! as string
.form
element with theaction
property.useActionState()
anduseFormStatus()
hooks in React 19.hybrid
output.Non-goals
required
andtype
properties on an input..astro
frontmatter. Frontmatter forms a function closure, which can lead to misuse of variables within an action handler. This challenge is shared bygetStaticPaths()
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:
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
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 theaction
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.
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.
The text was updated successfully, but these errors were encountered: