Skip to content

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.

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

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>
)
}
  1. Open /admin/formsNew form.
  2. 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.
  3. 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).
  4. 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.

---
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=.

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.

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 + _context metadata rendered as a table.
  • Bulk status change: select rows and use Mark as new/read/archived to moderate in bulk.
  • Status new entries count toward the unread badge on the sidebar.

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 (429 with Retry-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 _hp field. Bots typically fill every input, so non-empty _hp submissions 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.

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