Skip to content

Fix: Astro Actions Not Working — Form Submission Failing, Validation Errors Missing, or Return Type Wrong

FixDevs ·

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 data

Or 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 input

Why 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 the src/actions/ directory. The index.ts file exports a server object that defines all actions.
  • The experimental.actions flag must be enabled — in some Astro versions, actions require explicit opt-in in astro.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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles