Skip to main content
161

Search Lumen

Find components, APIs, guides, and recipes.

GitHub
Web docs
Astro integration

Server-first forms with Astro Actions

Let Astro parse and validate the request while Lumen renders the same values, fields, and errors before JavaScript runs.

Transport
FormData
Rendering
Server
Fallback
Native POST

Define a form Action

Use accept: 'form'. The Action owns parsing, schema validation, authorization, and the write. Lumen does not wrap defineAction or require a resolver.

ts
// src/actions/index.ts
import { defineAction } from 'astro:actions'
import { z } from 'astro:schema'

export const server = {
  updateProfile: defineAction({
    accept: 'form',
    input: z.object({
      email: z.string().email(),
      name: z.string().min(2)
    }),
    handler: async (input, context) => {
      // Authorize context.user before writing.
      await saveProfile(input)
      return { updated: true }
    }
  })
}
// src/actions/index.ts
import { defineAction } from 'astro:actions'
import { z } from 'astro:schema'

export const server = {
  updateProfile: defineAction({
    accept: 'form',
    input: z.object({
      email: z.string().email(),
      name: z.string().min(2)
    }),
    handler: async (input, context) => {
      // Authorize context.user before writing.
      await saveProfile(input)
      return { updated: true }
    }
  })
}

Render Action errors

Read the submitted result with Astro.getActionResult. normalizeAstroActionErrors turns ActionInputError fields into the shared ErrorSummary and FieldError contract.

astro
---
import { actions, isInputError } from 'astro:actions'
import {
  Button,
  ErrorSummary,
  Field,
  FieldError,
  Form,
  Input,
  Label,
  normalizeAstroActionErrors
} from '@santi020k/lumen-astro'

const result = Astro.getActionResult(actions.updateProfile)
const errors = result?.error && isInputError(result.error)
  ? normalizeAstroActionErrors(result.error, {
      email: 'profile-email',
      name: 'profile-name'
    })
  : normalizeAstroActionErrors(result?.error)

const emailError = errors.fields.find(error => error.name === 'email')
---

<Form action={actions.updateProfile} method="POST" status={result?.error ? 'error' : 'idle'}>
  <ErrorSummary errors={errors} />
  <Field controlId="profile-email" describedBy="profile-email-error">
    <Label for="profile-email">Email</Label>
    <Input
      aria-describedby={emailError ? 'profile-email-error' : undefined}
      aria-invalid={emailError ? 'true' : undefined}
      id="profile-email"
      name="email"
      required
      type="email"
    />
    <FieldError id="profile-email-error" message={emailError?.message} />
  </Field>
  <Button type="submit">Save profile</Button>
</Form>
---
import { actions, isInputError } from 'astro:actions'
import {
  Button,
  ErrorSummary,
  Field,
  FieldError,
  Form,
  Input,
  Label,
  normalizeAstroActionErrors
} from '@santi020k/lumen-astro'

const result = Astro.getActionResult(actions.updateProfile)
const errors = result?.error && isInputError(result.error)
  ? normalizeAstroActionErrors(result.error, {
      email: 'profile-email',
      name: 'profile-name'
    })
  : normalizeAstroActionErrors(result?.error)

const emailError = errors.fields.find(error => error.name === 'email')
---

<Form action={actions.updateProfile} method="POST" status={result?.error ? 'error' : 'idle'}>
  <ErrorSummary errors={errors} />
  <Field controlId="profile-email" describedBy="profile-email-error">
    <Label for="profile-email">Email</Label>
    <Input
      aria-describedby={emailError ? 'profile-email-error' : undefined}
      aria-invalid={emailError ? 'true' : undefined}
      id="profile-email"
      name="email"
      required
      type="email"
    />
    <FieldError id="profile-email-error" message={emailError?.message} />
  </Field>
  <Button type="submit">Save profile</Button>
</Form>

Files

Keep multipart encoding explicit. Validate file type and size on the server even when the client supplies accept.

astro
<Form
  action={actions.uploadAvatar}
  enctype="multipart/form-data"
  method="POST"
>
  <FileUpload accept="image/*" name="avatar" required />
  <Button type="submit">Upload avatar</Button>
</Form>
<Form
  action={actions.uploadAvatar}
  enctype="multipart/form-data"
  method="POST"
>
  <FileUpload accept="image/*" name="avatar" required />
  <Button type="submit">Upload avatar</Button>
</Form>

Production checklist

Authorize inside the Action before reading or writing private data.
Rate-limit sensitive submissions and keep CSRF decisions in the application.
After success, redirect to a GET route to avoid accidental resubmission.
Re-render rejected values and errors on the server; enhancement is optional.
Focus ErrorSummary only after a failed submit, not on every blur.
Web docsFull catalog