Forms
Forms collect submissions from site visitors — contact forms, newsletter signups, surveys. The CMS ships the server side: a managed submit endpoint (/api/cms/forms/submit/{slug}), admin support for viewing submissions, and email notifications. You add two collections and a render component to your project; editors then build forms visually in the admin.
Adding the collections
Section titled “Adding the collections”The submit endpoint expects a forms collection (definitions) and a form-submissions collection (captured entries). Add both and register them in cms.config.ts.
src/cms/collections/forms.ts:
import { defineCollection, fields } from "@kidecms/core";
export default defineCollection({ slug: "forms", labels: { singular: "Form", plural: "Forms" }, labelField: "title", timestamps: true, views: { list: { columns: ["title", "slug", "_updatedAt"] }, }, fields: { title: fields.text({ required: true }), slug: fields.slug({ from: "title", admin: { position: "sidebar" } }), submitRedirect: fields.text({ admin: { help: "Optional URL to redirect to after submit. Leave empty to stay on the page." }, }), successMessage: fields.text({ defaultValue: "Thanks — we got your message.", admin: { rows: 2 }, }), notificationEmail: fields.email({ admin: { position: "sidebar", help: "Send an email here on each submission (requires RESEND_API_KEY)." }, }), fields: fields.blocks({ shared: false, types: { text: { name: fields.text({ required: true, admin: { help: "Field name used in form data (e.g. name)" } }), label: fields.text({ required: true }), placeholder: fields.text(), maxLength: fields.number(), required: fields.boolean(), }, email: { name: fields.text({ required: true }), label: fields.text({ required: true }), placeholder: fields.text(), required: fields.boolean(), }, textarea: { name: fields.text({ required: true }), label: fields.text({ required: true }), placeholder: fields.text(), rows: fields.number({ defaultValue: 4 }), required: fields.boolean(), }, select: { name: fields.text({ required: true }), label: fields.text({ required: true }), options: fields.array({ of: fields.text(), defaultValue: [] }), required: fields.boolean(), }, checkbox: { name: fields.text({ required: true }), label: fields.text({ required: true }), required: fields.boolean(), }, }, }), },});src/cms/collections/form-submissions.ts:
import { defineCollection, fields } from "@kidecms/core";
export default defineCollection({ slug: "form-submissions", labels: { singular: "Submission", plural: "Submissions" }, labelField: "label", timestamps: true, admin: { group: "Library", icon: "Inbox", weight: 45 }, views: { list: { columns: ["label", "form", "_createdAt", "status"] }, }, fields: { label: fields.text({ admin: { hidden: true } }), form: fields.relation({ collection: "forms", required: true }), status: fields.select({ options: ["new", "read", "archived"], defaultValue: "new", admin: { position: "sidebar" }, }), data: fields.json({ admin: { help: "Submitted form data (read-only)." } }), }, hooks: { beforeCreate(data) { if (!data.label) { const submitted = (data.data ?? {}) as Record<string, unknown>; const firstValue = Object.values(submitted).find((v) => typeof v === "string" && v.trim()) as | string | undefined; data.label = firstValue ? firstValue.slice(0, 40) : "Submission"; } return data; }, },});Adding the component
Section titled “Adding the component”src/components/CmsForm.astro fetches the form definition by slug, renders native HTML inputs (no client JS), and submits to the managed endpoint. Style to taste:
---import { cms } from "@/cms/.generated/api";
type FormFieldConfig = { type: "text" | "email" | "textarea" | "select" | "checkbox"; name: string; label: string; placeholder?: string; required?: boolean; maxLength?: number; rows?: number; options?: string[];};
const { slug, context } = Astro.props as { slug: string; context?: Record<string, string | number | boolean> };const form = await cms.forms.findOne({ slug });const fieldConfigs = form && Array.isArray(form.fields) ? (form.fields as FormFieldConfig[]) : [];
const submitted = Astro.url.searchParams.get("submitted") === "1";const formError = Astro.url.searchParams.get("formError");---
{!form && <p>This form has not been set up yet. Create one with the slug "{slug}" in the admin UI.</p>}
{ form && ( <form method="POST" action={`/api/cms/forms/submit/${slug}`}> {/* Honeypot — bots fill visible inputs; humans never see this one */} <input type="text" name="_hp" tabindex="-1" autocomplete="off" aria-hidden="true" style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0;" />
{/* Context metadata set by the host page */} {context && Object.entries(context).map(([key, value]) => ( <input type="hidden" name={`_ctx_${key}`} value={String(value)} /> ))}
{fieldConfigs.map((field) => { if (field.type === "textarea") { return ( <label> {field.label} <textarea name={field.name} rows={field.rows ?? 4} placeholder={field.placeholder ?? ""} required={field.required} /> </label> ); } if (field.type === "select") { return ( <label> {field.label} <select name={field.name} required={field.required}> <option value="">Select…</option> {(field.options ?? []).map((opt) => ( <option value={opt}>{opt}</option> ))} </select> </label> ); } if (field.type === "checkbox") { return ( <label> <input type="checkbox" name={field.name} required={field.required} /> {field.label} </label> ); } // text | email return ( <label> {field.label} <input type={field.type} name={field.name} placeholder={field.placeholder ?? ""} required={field.required} maxlength={field.maxLength} /> </label> ); })}
<button type="submit">Submit</button>
{submitted && <p>{form.successMessage || "Thanks — we got your message."}</p>} {formError && <p>{formError}</p>} </form> )}Creating a form in the admin
Section titled “Creating a form in the admin”- Open
/admin/forms→ New form. - Give it a title (e.g., “Contact”). The slug auto-generates from the title and is used in the public component to reference this form.
- Optionally set:
- Redirect URL after submit: redirect visitors to a thank-you page.
- Success message: text shown on the form page after a successful submit (falls back to “Thanks — we got your message.” if empty).
- Notification email: an address to email on every submission (requires
RESEND_API_KEY).
- Add fields — each field is a block. Supported types:
| Type | Options |
|---|---|
text |
name, label, placeholder, required, maxLength |
email |
name, label, placeholder, required |
textarea |
name, label, placeholder, required, rows |
select |
name, label, required, options (array of strings) |
checkbox |
name, label, required |
name is the form data key (e.g., email), used in the stored submission’s data object.
label is what the visitor sees.
Rendering on the public site
Section titled “Rendering on the public site”---import CmsForm from "@/components/CmsForm.astro";---
<CmsForm slug="contact" />The form submits to /api/cms/forms/submit/{slug} and redirects back with ?submitted=1 on success so the success message appears (or to the form’s redirect URL, if set). Validation failures redirect back with the messages in ?formError=.
Attaching page context
Section titled “Attaching page context”To know which page a submission came from, pass a context prop:
---import CmsForm from "@/components/CmsForm.astro";import { cms } from "@/cms/.generated/api";
const page = await cms.pages.findOne({ slug: Astro.params.slug! });---
<CmsForm slug="contact" context={{ pageTitle: page.title, pageUrl: Astro.url.pathname }} />Each key-value pair becomes a hidden input prefixed with _ctx_. The server merges them into the submission’s data._context object, visible alongside submitted fields on the submission detail page.
Viewing and moderating submissions
Section titled “Viewing and moderating submissions”Forms appear in the admin sidebar with a muted “N new” count. Open a form and click the Submissions tab to see its entries:
- Each row: submitted date, status, preview of the data.
- Clicking a row opens a read-only detail view with all submitted fields +
_contextmetadata rendered as a table. - Bulk status change: select rows and use Mark as new/read/archived to moderate in bulk.
- Status
newentries count toward the unread badge on the sidebar.
Endpoint protections
Section titled “Endpoint protections”The submit endpoint validates values server-side against the form definition (required, email format, maxLength, select options) and enforces abuse bounds:
- Rate limit: 10 submissions per IP per 10 minutes (
429withRetry-After). - Body cap: 100 KB, enforced on the raw byte stream (
413). - Field caps: max 100 fields per submission, max 10,000 characters per value.
- Honeypot: the rendered form includes a hidden
_hpfield. Bots typically fill every input, so non-empty_hpsubmissions are silently accepted but never stored.
This is enough for most small sites. For heavier traffic, add a beforeCreate hook on form-submissions that calls Turnstile or reCAPTCHA.
How it’s wired
Section titled “How it’s wired”| Piece | Location |
|---|---|
| Form definition collection | src/cms/collections/forms.ts (yours) |
| Submission storage collection | src/cms/collections/form-submissions.ts (yours) |
| Public render component | src/components/CmsForm.astro (yours) |
| Submit endpoint | src/cms/routes/api/forms/submit/[slug].ts |
| Email notification | sendFormSubmissionEmail in src/cms/adapters/email.ts |
| Auth middleware public-route allowlist | src/cms/middleware/auth.ts |