Form Validation

The form is novalidate — no native HTML validation. The real rules live in a Zod schema on the server. @post(…, {contentType: 'form'}) submits the fields as form data; on failure the endpoint re-renders the same <ContactForm/> component with the submitted values and per-field errors (aria-invalid + aria-describedby wired up), and on success it re-renders it in the thanks state. One component, three states, written once.

Contact form

Try submitting empty, or with a bad email — the errors come back from the server as a morphed fragment, values intact.

The shared component

src/components/ContactForm.astro
---
/** Contact form shared by the page and /api/contact (pristine, errored, success). */
export type FieldErrors = Partial<
  Record<'name' | 'email' | 'message', string[]>
>;

interface Props {
  values?: { name?: string; email?: string; message?: string };
  errors?: FieldErrors;
  success?: boolean;
}

const { values = {}, errors = {}, success = false } = Astro.props;

const field = (name: keyof FieldErrors) => ({
  invalid: !!errors[name]?.length,
  errorId: `contact-${name}-error`,
});
---

<div id="contact-form">
  {
    success ? (
      <p role="status">
        <strong>Thanks, {values.name}!</strong> We'll reply to {values.email}.
      </p>
    ) : (
      <form
        novalidate
        class="ds-busy-fade"
        data-on:submit="@post('/api/contact', {contentType: 'form'})"
        data-indicator="_sending"
        data-attr:aria-busy="$_sending ? 'true' : 'false'"
      >
        <p>
          <label for="cname">Name</label>
          <input
            id="cname"
            name="name"
            type="text"
            value={values.name}
            aria-invalid={field('name').invalid ? 'true' : undefined}
            aria-describedby={field('name').invalid ? field('name').errorId : undefined}
          />
          {field('name').invalid && (
            <span class="field-error" id={field('name').errorId}>
              {errors.name![0]}
            </span>
          )}
        </p>
        <p>
          <label for="cemail">Email</label>
          <input
            id="cemail"
            name="email"
            type="email"
            value={values.email}
            aria-invalid={field('email').invalid ? 'true' : undefined}
            aria-describedby={field('email').invalid ? field('email').errorId : undefined}
          />
          {field('email').invalid && (
            <span class="field-error" id={field('email').errorId}>
              {errors.email![0]}
            </span>
          )}
        </p>
        <p>
          <label for="cmsg">Message</label>
          <textarea
            id="cmsg"
            name="message"
            rows="3"
            aria-invalid={field('message').invalid ? 'true' : undefined}
            aria-describedby={field('message').invalid ? field('message').errorId : undefined}>{values.message}</textarea>
          {field('message').invalid && (
            <span class="field-error" id={field('message').errorId}>
              {errors.message![0]}
            </span>
          )}
        </p>
        <button
          data-attr:disabled="$_sending"
          data-text="$_sending ? 'Sending…' : 'Send'"
        >
          Send
        </button>
      </form>
    )
  }
</div>

The endpoint

src/pages/api/contact.ts
import { html, readSignals } from '@wrux/astro-datastar/server';
import type { APIRoute } from 'astro';
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { z } from 'astro/zod';
import ContactForm from '../../components/ContactForm.astro';

export const prerender = false;

const contact = z.object({
  name: z
    .string()
    .trim()
    .min(2, 'Please give your name (at least 2 characters).'),
  email: z.string().trim().email('That email address doesn’t look right.'),
  message: z
    .string()
    .trim()
    .min(10, 'Tell us a little more — at least 10 characters.')
    .max(500, 'Keep it under 500 characters.'),
});

export const POST: APIRoute = async ({ request }) => {
  // Read raw so failures re-render the form with the submitted values.
  const raw = await readSignals<Record<string, string>>(request);
  const result = contact.safeParse(raw);

  const container = await AstroContainer.create();

  if (!result.success) {
    return html(
      await container.renderToString(ContactForm, {
        props: {
          values: raw,
          errors: result.error.flatten().fieldErrors,
        },
      }),
    );
  }

  // Simulate a slow mail handoff so the indicator is visible.
  await new Promise((r) => setTimeout(r, 1_000));

  return html(
    await container.renderToString(ContactForm, {
      props: { values: result.data, success: true },
    }),
  );
};