Fix: Astro Actions Not Working — Form Submission Failing, Validation Errors Missing, or Return Type Wrong
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.
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
});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.
For related Astro issues, see Fix: Astro 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.