Fix: Astro Actions Not Working — Form Submission Failing, Validation Errors Missing, or Return Type Wrong
Part of: React & Frontend Errors
Quick Answer
How to fix Astro Actions issues — action definition, Zod validation, form handling, progressive enhancement, error handling, file uploads, and calling actions from client scripts.
The Problem
An Astro Action returns undefined:
import { actions } from 'astro:actions';
const result = await actions.createPost({ title: 'Hello', body: 'World' });
// result is undefined — no error, no dataOr form submission doesn’t trigger the action:
<form method="POST" action={actions.createPost}>
<input name="title" />
<button>Submit</button>
</form>
<!-- Form submits but nothing happens -->Or validation errors don’t appear:
const { error } = await actions.createPost({ title: '' });
// error is undefined even with invalid inputWhy This Happens
Astro Actions are type-safe server functions introduced in Astro 4.15+. They handle form submissions and API calls with built-in validation:
- Actions must be defined in
src/actions/— Astro discovers actions from files in thesrc/actions/directory. Theindex.tsfile exports aserverobject that defines all actions. - The
experimental.actionsflag must be enabled — in some Astro versions, actions require explicit opt-in inastro.config.mjs. - Actions use Zod for validation — input validation is defined with
defineAction({ input: z.object(...) }). Invalid input returns a structured error, not a thrown exception. - Form actions need
method="POST"— HTML forms must use POST. The action’s handler function receives the validated input.
The runtime model is also different from a typical REST handler. Astro Actions are RPC-style: the client imports the action by name, calls it like a function, and gets back a { data, error } tuple. Under the hood, Astro generates a POST route at /_actions/<actionName> and serializes arguments as JSON or multipart form data depending on accept. If you mix the two — for example calling a accept: 'json' action from an HTML form, or a accept: 'form' action with a plain object — the server can’t parse the payload and the action returns a generic BAD_REQUEST with empty fields.
A second source of confusion is how Astro decides whether to run server code at all. Actions only execute when the page is server-rendered. That means setting output: 'static' (the default for older projects) breaks every action, even ones that look correct in dev. Dev mode runs a server regardless of config, so the bug usually appears only after astro build and a production deploy — the form submits, the page reloads, nothing changes, and there’s no obvious error in the network tab.
Fix 1: Define Actions
// src/actions/index.ts — all actions defined here
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
// Simple action with validation
createPost: defineAction({
accept: 'json', // or 'form' for HTML form submissions
input: z.object({
title: z.string().min(1, 'Title is required').max(200),
body: z.string().min(10, 'Body must be at least 10 characters'),
tags: z.array(z.string()).max(5).default([]),
published: z.boolean().default(false),
}),
handler: async (input, context) => {
// input is validated and typed
const post = await db.insert(posts).values({
title: input.title,
body: input.body,
tags: input.tags,
published: input.published,
authorId: context.locals.user?.id,
}).returning();
return post[0];
},
}),
// Action for form submissions (HTML forms)
submitContact: defineAction({
accept: 'form',
input: z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(10).max(1000),
}),
handler: async (input) => {
await sendEmail({
to: '[email protected]',
subject: `Contact from ${input.name}`,
body: input.message,
replyTo: input.email,
});
return { success: true };
},
}),
// Action without input validation
getServerTime: defineAction({
handler: async () => {
return { time: new Date().toISOString() };
},
}),
// Action with authentication check
deletePost: defineAction({
accept: 'json',
input: z.object({
id: z.number(),
}),
handler: async (input, context) => {
const user = context.locals.user;
if (!user) {
throw new ActionError({
code: 'UNAUTHORIZED',
message: 'You must be logged in',
});
}
const post = await db.query.posts.findFirst({
where: eq(posts.id, input.id),
});
if (!post || post.authorId !== user.id) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'You can only delete your own posts',
});
}
await db.delete(posts).where(eq(posts.id, input.id));
return { deleted: true };
},
}),
};// astro.config.mjs — enable actions
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'server', // or 'hybrid' — actions need server runtime
});How Other Tools Handle This
Server-side form handling has converged on similar patterns across frameworks, but the ergonomics and constraints differ enough to change how you write the same feature.
Next.js Server Actions use the 'use server' directive at the top of a function or file. Calls are RPC-style, but the wire format is a custom binary protocol over POST, and arguments are serialized with React’s flight format — not JSON. That means Server Actions can pass non-JSON values (Dates, Maps, FormData), but they cannot be invoked from non-React clients. Validation is not built-in; you typically add Zod or next-safe-action yourself. Astro Actions return { data, error } explicitly, while Next.js Server Actions throw on failure and rely on React error boundaries or useFormState.
Remix actions are tied to routes. Every route can export an action function that receives a Request and returns a Response (or plain data). There is no RPC layer — forms post to the route URL, and progressive enhancement is automatic via <Form>. Astro’s accept: 'form' action behaves similarly, but Astro centralizes actions in src/actions/ instead of per-route, which scales better for shared business logic but worse for route-local validation.
SvelteKit form actions are the closest match conceptually: you export named actions from a +page.server.ts and post to them via <form action="?/actionName">. SvelteKit’s fail() helper returns validation errors with the same { data, errors } shape Astro produces. The main difference is type safety — Astro’s Zod input is fully typed end-to-end, whereas SvelteKit’s formData is unknown until you parse it yourself.
Nuxt does not have a direct equivalent. The usual pattern is useFetch('/api/endpoint', { method: 'POST' }) paired with a server route under server/api/. There’s no automatic validation, no progressive enhancement helper, and no { data, error } envelope unless you build one. Nuxt’s strength is the broader server runtime (Nitro); Astro’s strength is the typed contract between client and server.
If you’re migrating from Server Actions or Remix, the biggest mental switch is that Astro Actions always return — they never throw across the network boundary. Always destructure { data, error } and check error before using data. See Fix: Next.js Server Action Not Working and Fix: SvelteKit Not Working for the same problems in adjacent frameworks.
Fix 2: Call Actions from Client Scripts
---
// src/pages/posts/new.astro
---
<h1>New Post</h1>
<form id="post-form">
<input name="title" placeholder="Title" required />
<textarea name="body" placeholder="Write your post..." required></textarea>
<label>
<input type="checkbox" name="published" /> Publish immediately
</label>
<button type="submit">Create Post</button>
<p id="error" style="color: red; display: none;"></p>
<p id="success" style="color: green; display: none;"></p>
</form>
<script>
import { actions } from 'astro:actions';
const form = document.getElementById('post-form') as HTMLFormElement;
const errorEl = document.getElementById('error')!;
const successEl = document.getElementById('success')!;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const data = {
title: formData.get('title') as string,
body: formData.get('body') as string,
published: formData.has('published'),
tags: [],
};
const { data: post, error } = await actions.createPost(data);
if (error) {
errorEl.textContent = error.message;
errorEl.style.display = 'block';
successEl.style.display = 'none';
// Field-level validation errors
if (error.fields) {
console.log(error.fields);
// { title: ['Title is required'], body: ['Body must be at least 10 characters'] }
}
return;
}
successEl.textContent = `Post "${post.title}" created!`;
successEl.style.display = 'block';
errorEl.style.display = 'none';
form.reset();
});
</script>Fix 3: Progressive Enhancement (HTML Forms)
---
// src/pages/contact.astro
import { actions } from 'astro:actions';
// Handle form result after submission
const result = Astro.getActionResult(actions.submitContact);
const inputErrors = result?.error?.fields;
---
<h1>Contact Us</h1>
{result?.data?.success && (
<div class="success">Thank you! We'll get back to you soon.</div>
)}
{result?.error && !result.error.fields && (
<div class="error">{result.error.message}</div>
)}
<!-- Progressive enhancement: works without JS -->
<form method="POST" action={actions.submitContact}>
<div>
<label for="name">Name</label>
<input id="name" name="name" required />
{inputErrors?.name && <p class="field-error">{inputErrors.name}</p>}
</div>
<div>
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
{inputErrors?.email && <p class="field-error">{inputErrors.email}</p>}
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
{inputErrors?.message && <p class="field-error">{inputErrors.message}</p>}
</div>
<button type="submit">Send Message</button>
</form>
<style>
.success { color: green; padding: 12px; background: #f0fff4; border-radius: 8px; }
.error { color: red; padding: 12px; background: #fff0f0; border-radius: 8px; }
.field-error { color: red; font-size: 0.875rem; margin-top: 4px; }
</style>Fix 4: React / Vue / Svelte Island Integration
// src/components/PostForm.tsx — React component calling Astro Actions
import { actions } from 'astro:actions';
import { useState } from 'react';
export function PostForm() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
const { data, error: actionError } = await actions.createPost({
title,
body,
tags: [],
published: false,
});
setLoading(false);
if (actionError) {
setError(actionError.message);
return;
}
// Redirect to the new post
window.location.href = `/posts/${data.slug}`;
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="Title" />
<textarea value={body} onChange={e => setBody(e.target.value)} placeholder="Body" />
{error && <p style={{ color: 'red' }}>{error}</p>}
<button disabled={loading}>{loading ? 'Creating...' : 'Create'}</button>
</form>
);
}---
// src/pages/posts/new.astro — use React island
import { PostForm } from '@/components/PostForm';
---
<h1>New Post</h1>
<PostForm client:load />Fix 5: Error Handling
// src/actions/index.ts
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
riskyAction: defineAction({
input: z.object({ id: z.number() }),
handler: async (input, context) => {
// Throw typed errors
if (!context.locals.user) {
throw new ActionError({
code: 'UNAUTHORIZED',
message: 'Please log in first',
});
}
try {
const result = await externalApi.process(input.id);
return result;
} catch (err) {
// Wrap unexpected errors
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Processing failed. Please try again.',
});
}
},
}),
};
// Client-side error handling
const { data, error } = await actions.riskyAction({ id: 123 });
if (error) {
switch (error.code) {
case 'UNAUTHORIZED':
window.location.href = '/login';
break;
case 'BAD_REQUEST':
showValidationErrors(error.fields);
break;
case 'INTERNAL_SERVER_ERROR':
showToast('Something went wrong');
break;
}
}Fix 6: File Uploads
// src/actions/index.ts
export const server = {
uploadAvatar: defineAction({
accept: 'form',
input: z.object({
avatar: z.instanceof(File)
.refine(f => f.size <= 5 * 1024 * 1024, 'Max 5MB')
.refine(f => ['image/jpeg', 'image/png', 'image/webp'].includes(f.type), 'Invalid format'),
}),
handler: async (input, context) => {
const buffer = Buffer.from(await input.avatar.arrayBuffer());
const filename = `${crypto.randomUUID()}.${input.avatar.type.split('/')[1]}`;
// Save to storage
const url = await uploadToStorage(buffer, filename);
// Update user profile
await db.update(users)
.set({ avatar: url })
.where(eq(users.id, context.locals.user.id));
return { url };
},
}),
};<form method="POST" action={actions.uploadAvatar} enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/*" required />
<button type="submit">Upload</button>
</form>Still Not Working?
Action returns undefined — check the return value of actions.myAction(). It returns { data, error }. If both are undefined, the action file isn’t being found. Verify src/actions/index.ts exists and exports a server object.
“Actions require server output” — set output: 'server' or output: 'hybrid' in astro.config.mjs. Actions need a server runtime — they can’t work with output: 'static'.
Form submission reloads the page with no result — for progressive enhancement forms, use Astro.getActionResult() in the page’s frontmatter to read the result. For client-side handling, use e.preventDefault() and call the action via the JS API.
Validation errors are empty — check that input in defineAction uses Zod schemas with error messages: z.string().min(1, 'Required'). The error.fields object maps field names to arrays of error messages.
Action works in dev but 404s in production — your adapter is missing or output is 'static'. Verify astro.config.mjs has output: 'server' or 'hybrid', and that the deployed environment uses the right adapter (@astrojs/node, @astrojs/vercel, @astrojs/cloudflare). On Cloudflare Pages especially, a missing _routes.json will silently 404 the /_actions/* paths.
FormData fields arrive as null — your action is declared accept: 'json' but the form posts multipart. Switch the action to accept: 'form', or convert the FormData to an object before calling the action from JS. The wire format must match what defineAction expects.
Cookies set inside an action don’t appear on the client — context.cookies.set() only writes headers when the action is called over a real HTTP request. If you call the action from a client:load island during hydration without a fresh round trip, the browser already has stale cookies. Use a full form submission or a manual fetch to /_actions/<name> so the response Set-Cookie header is honored.
For related Astro issues, see Fix: Nuxt Not Working and Fix: Astro DB Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Analog Not Working — Routes Not Loading, API Endpoints Failing, or Vite Build Errors
How to fix Analog (Angular meta-framework) issues — file-based routing, API routes with Nitro, content collections, server-side rendering, markdown pages, and deployment.
Fix: Angular SSR Not Working — Hydration Failing, Window Not Defined, or Build Errors
How to fix Angular Server-Side Rendering issues — @angular/ssr setup, hydration, platform detection, transfer state, route-level rendering, and deployment configuration.
Fix: Astro DB Not Working — Tables Not Found, Queries Failing, or Seed Data Missing
How to fix Astro DB issues — schema definition, seed data, queries with drizzle, local development, remote database sync, and Astro Studio integration.
Fix: SolidStart Not Working — Routes Not Rendering, Server Functions Failing, or Hydration Errors
How to fix SolidStart issues — file-based routing, server functions, createAsync data loading, middleware, sessions, and deployment configuration.