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
---
/** 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
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 },
}),
);
};