Forms
Forms let you collect data from site visitors — contact enquiries, newsletter sign-ups, surveys, event registrations — and store submissions as flat files. The form system handles definition, frontend rendering, server-side validation, spam protection, and data export without requiring external services.
A form in Madori has two parts:
- A blueprint that defines the form's fields (what data you collect)
- A definition that configures behaviour (honeypot protection, submission storage)
Submissions are stored as timestamped YAML files and can be viewed in the Control Panel or exported as CSV/JSON.
Configuration Reference
Form Blueprint
Form blueprints define the fields visitors fill in. They live at resources/blueprints/forms/{handle}.yaml and follow the same structure as collection blueprints.
# resources/blueprints/forms/contact.yaml
tabs:
main:
display: Form Fields
fields:
- handle: name
field:
type: text
display: Full Name
required: true
validate:
- required
- min:2
- max:100
- handle: email
field:
type: text
display: Email Address
required: true
validate:
- required
- email
- handle: message
field:
type: markdown
display: Message
required: true
validate:
- required
- min:10
- max:5000
All field types and validation rules available in collection blueprints work in form blueprints.
Form Definition
Form definitions configure runtime behaviour. They live at resources/forms/{handle}.yaml:
# resources/forms/contact.yaml
title: Contact Form
blueprint: contact
honeypot: true
store_submissions: true
| Property | Type | Default | Description |
|---|---|---|---|
title |
string |
— | Display name shown in the Control Panel |
blueprint |
string |
— | Handle of the form blueprint to use |
honeypot |
boolean |
false |
Enable honeypot spam protection |
store_submissions |
boolean |
true |
Whether to persist submissions as files |
File Structure
resources/
├── blueprints/forms/
│ ├── contact.yaml # Field definitions
│ └── newsletter.yaml
├── forms/
│ ├── contact.yaml # Form definition (behaviour config)
│ └── newsletter.yaml
content/
└── forms/
├── contact/ # Stored submissions
│ ├── 2026-01-15T10-30-45-abc123.yaml
│ └── 2026-01-16T09-15-22-def456.yaml
└── newsletter/
Submission Storage Format
Each submission is stored as a YAML file at content/forms/{handle}/{timestamp}-{uuid}.yaml:
id: "550e8400-e29b-41d4-a716-446655440000"
form: "contact"
submitted_at: "2026-01-15T10:30:45.000Z"
data:
name: "Jane Smith"
email: "[email protected]"
message: "Hello, I'd like to learn more about your services."
The filename combines a timestamp (for chronological sorting) and a UUID (for uniqueness). The data field contains the submitted values keyed by field handle.
Frontend Integration
Rendering a Form
Build your form HTML using the field handles defined in your blueprint. Submit to the form's API endpoint:
<form action="/api/forms/contact/submit" method="POST" id="contact-form">
<div>
<label for="name">Full Name</label>
<input type="text" id="name" name="name" required minlength="2" maxlength="100" />
</div>
<div>
<label for="email">Email Address</label>
<input type="email" id="email" name="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" required minlength="10" maxlength="5000"></textarea>
</div>
<!-- Honeypot field — hidden from real users -->
<div style="position: absolute; left: -9999px;" aria-hidden="true">
<input type="text" name="_honeypot" tabindex="-1" autocomplete="off" />
</div>
<button type="submit">Send Message</button>
</form>
Submitting with JavaScript
For a better user experience, submit forms via fetch and handle validation errors inline:
const form = document.getElementById('contact-form')
form.addEventListener('submit', async (event) => {
event.preventDefault()
const formData = new FormData(form)
const data = Object.fromEntries(formData.entries())
const response = await fetch('/api/forms/contact/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
// Submission successful
form.reset()
showMessage('Thank you! Your message has been sent.')
} else {
const result = await response.json()
if (result.error?.code === 'VALIDATION_ERROR') {
// Display field-level errors
displayFieldErrors(result.error.fields)
}
}
})
function displayFieldErrors(fields) {
// Clear previous errors
document.querySelectorAll('.field-error').forEach(el => el.remove())
for (const [handle, messages] of Object.entries(fields)) {
const input = document.querySelector(`[name="${handle}"]`)
if (input) {
const errorEl = document.createElement('span')
errorEl.className = 'field-error'
errorEl.textContent = messages[0]
input.parentNode.appendChild(errorEl)
}
}
}
React Integration
For React/Next.js projects, use the built-in MadoriForm and FormField components:
import { MadoriForm, FormField } from '@/components/site/MadoriForm'
export function ContactForm() {
return (
<MadoriForm
handle="contact"
successMessage={<p>Thank you! Your message has been sent.</p>}
className="space-y-4"
>
{({ errors, submitting }) => (
<>
<FormField handle="name" label="Full Name" errors={errors.name}>
<input type="text" id="name" name="name" required />
</FormField>
<FormField handle="email" label="Email Address" errors={errors.email}>
<input type="email" id="email" name="email" required />
</FormField>
<FormField handle="message" label="Message" errors={errors.message}>
<textarea id="message" name="message" required />
</FormField>
{/* Honeypot */}
<input type="text" name="_honeypot" style={{ display: 'none' }} tabIndex={-1} />
<button type="submit" disabled={submitting}>
{submitting ? 'Sending...' : 'Send Message'}
</button>
</>
)}
</MadoriForm>
)
}
The MadoriForm component:
- Submits to
/api/forms/{handle}/submitautomatically - Catches
VALIDATION_ERRORresponses and exposes field-level errors via the render prop - Displays a success message after successful submission
The FormField component:
- Wraps each input with a label and error display area
- Shows validation errors adjacent to the field using
role="alert"for accessibility - Accepts
errorsfrom the parent render prop keyed by field handle
If you prefer building the form manually, you can handle the validation response directly:
Manual React Integration
import { useState } from 'react'
interface FormErrors {
[field: string]: string[]
}
export function ContactForm() {
const [errors, setErrors] = useState<FormErrors>({})
const [submitted, setSubmitted] = useState(false)
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setErrors({})
const formData = new FormData(event.currentTarget)
const data = Object.fromEntries(formData.entries())
const response = await fetch('/api/forms/contact/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
setSubmitted(true)
} else {
const result = await response.json()
if (result.error?.fields) {
setErrors(result.error.fields)
}
}
}
if (submitted) {
return <p>Thank you! Your message has been sent.</p>
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Full Name</label>
<input type="text" id="name" name="name" required />
{errors.name && <span className="error">{errors.name[0]}</span>}
</div>
<div>
<label htmlFor="email">Email Address</label>
<input type="email" id="email" name="email" required />
{errors.email && <span className="error">{errors.email[0]}</span>}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{errors.message && <span className="error">{errors.message[0]}</span>}
</div>
{/* Honeypot */}
<input type="text" name="_honeypot" style={{ display: 'none' }} tabIndex={-1} />
<button type="submit">Send Message</button>
</form>
)
}
Validation Error Response
When submission data fails validation, the API returns a 422 response with field-level errors:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"fields": {
"email": ["Must be a valid email address"],
"message": ["Must be at least 10 characters"]
}
}
}
Each key in fields maps to a field handle from the blueprint, and the value is an array of error messages. Display these adjacent to the corresponding form inputs.
Submission Handling
How Submissions Are Processed
When a visitor submits a form:
- The API receives the POST request at
/api/forms/{handle}/submit - If honeypot protection is enabled, the
_honeypotfield is checked — if filled, the submission is silently discarded (returns201to avoid revealing detection) - The submission data is validated against the form blueprint's validation rules
- If validation passes, the submission is stored as a YAML file in
content/forms/{handle}/ - A success response is returned with the submission data
Viewing in the Control Panel
Navigate to Forms in the CP sidebar to see all defined forms. Click a form to view its submissions:
- Submissions are listed with timestamp and summary fields
- Click any submission to view all submitted field values in a read-only detail view
- Submissions are sorted newest-first by default
Deleting Submissions
In the submission detail view, click Delete and confirm. Deleted submissions are permanently removed from the filesystem and cannot be recovered.
Export
CSV Export
Export all submissions for a form as a comma-separated values file:
GET /api/forms/{handle}/export/csv
The CSV includes:
idandsubmitted_atcolumns for every submission- One column per unique field handle found across all submissions (sorted alphabetically)
- Values containing commas, quotes, or newlines are properly escaped
Open the downloaded file in Excel, Google Sheets, or any spreadsheet application.
JSON Export
Export all submissions as a JSON array:
GET /api/forms/{handle}/export/json
Returns a JSON array where each element contains:
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"form": "contact",
"submittedAt": "2026-01-15T10:30:45.000Z",
"data": {
"name": "Jane Smith",
"email": "[email protected]",
"message": "Hello, I'd like to learn more."
}
}
]
Both exports are also available via buttons in the Control Panel form detail view.
Honeypot Protection
Honeypot protection filters bot submissions without requiring CAPTCHAs or third-party services. When enabled, a hidden field is added to your form that real users never see or fill in. Bots that auto-fill all fields will trigger the filter.
Enabling Honeypot
Set honeypot: true in your form definition:
# resources/forms/contact.yaml
title: Contact Form
blueprint: contact
honeypot: true
Frontend Implementation
Add a hidden input named _honeypot to your form. It must be visually hidden but present in the DOM:
<!-- Hidden from human users via CSS positioning -->
<div style="position: absolute; left: -9999px;" aria-hidden="true">
<label for="website">Website</label>
<input type="text" id="website" name="_honeypot" tabindex="-1" autocomplete="off" />
</div>
Do not use display: none or visibility: hidden — some bots detect these and skip the field. Instead, position the field offscreen.
How It Works
- A real user submits the form — the
_honeypotfield is empty - The server checks the honeypot field value
- If empty → submission is processed normally
- If filled → submission is silently discarded
- Both cases return a
201status to avoid revealing the filtering to bots
The honeypot field is stripped from submission data before storage, so it never appears in your stored submissions or exports.
API Reference
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/forms |
List all form definitions |
| GET | /api/forms/{handle} |
Get a single form definition |
| POST | /api/forms/{handle}/submit |
Submit form data |
| GET | /api/forms/{handle}/submissions |
List submissions (paginated) |
| GET | /api/forms/{handle}/submissions/{id} |
Get a single submission |
| DELETE | /api/forms/{handle}/submissions/{id} |
Delete a submission |
| GET | /api/forms/{handle}/export/csv |
Export submissions as CSV |
| GET | /api/forms/{handle}/export/json |
Export submissions as JSON |
Pagination Parameters
The submissions list endpoint accepts query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
number |
1 |
Page number (1-indexed) |
perPage |
number |
20 |
Results per page (max 100) |
sort |
string |
newest |
Sort order: newest or oldest |
Response includes pagination metadata:
{
"data": {
"submissions": [...],
"total": 47,
"page": 1,
"perPage": 20
}
}
Usage Examples
Contact Form
A standard contact form with name, email, subject selection, and message body:
Blueprint (resources/blueprints/forms/contact.yaml):
tabs:
main:
display: Form Fields
fields:
- handle: name
field:
type: text
display: Full Name
required: true
validate:
- required
- min:2
- max:100
- handle: email
field:
type: text
display: Email Address
required: true
validate:
- required
- email
- handle: subject
field:
type: select
display: Subject
required: true
options:
options:
- General Inquiry
- Support
- Feedback
- Partnership
- handle: message
field:
type: markdown
display: Message
required: true
validate:
- required
- min:10
- max:5000
Definition (resources/forms/contact.yaml):
title: Contact Form
blueprint: contact
honeypot: true
store_submissions: true
Newsletter Sign-up
A minimal form collecting just an email address:
Blueprint (resources/blueprints/forms/newsletter.yaml):
tabs:
main:
fields:
- handle: email
field:
type: text
display: Email Address
required: true
validate:
- required
- email
- handle: interests
field:
type: multiselect
display: Interests
options:
options:
- Product Updates
- Engineering Blog
- Community Events
Definition (resources/forms/newsletter.yaml):
title: Newsletter
blueprint: newsletter
honeypot: true
store_submissions: true
Event Registration
A more complex form with conditional fields:
Blueprint (resources/blueprints/forms/event-registration.yaml):
tabs:
main:
display: Registration
fields:
- handle: full_name
field:
type: text
display: Full Name
required: true
validate:
- required
- max:200
- handle: email
field:
type: text
display: Email
required: true
validate:
- required
- email
- handle: ticket_type
field:
type: select
display: Ticket Type
required: true
default: general
options:
options:
- general
- vip
- student
- handle: dietary_requirements
field:
type: text
display: Dietary Requirements
options:
placeholder: Leave blank if none
visibility:
field: ticket_type
operator: not_equals
value: student
- handle: company
field:
type: text
display: Company Name
visibility:
field: ticket_type
operator: equals
value: vip
Common Patterns
Client-Side Validation Before Submission
Mirror your blueprint's validation rules in HTML attributes for immediate feedback, then rely on server-side validation as the source of truth:
<input
type="email"
name="email"
required
pattern="[^@]+@[^@]+\.[^@]+"
title="Please enter a valid email address"
/>
The server always validates regardless of client-side checks, so validation cannot be bypassed.
Redirect After Submission
Redirect users to a thank-you page after successful submission:
const response = await fetch('/api/forms/contact/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
window.location.href = '/thank-you'
}
Multiple Forms on One Page
Use different form handles and submit to their respective endpoints:
<form action="/api/forms/contact/submit" method="POST">
<!-- Contact form fields -->
</form>
<form action="/api/forms/newsletter/submit" method="POST">
<!-- Newsletter fields -->
</form>
Progressive Enhancement
Build forms that work without JavaScript, then enhance with async submission:
<form action="/api/forms/contact/submit" method="POST" id="contact-form">
<!-- Fields -->
<button type="submit">Send</button>
</form>
<script>
// Enhance with JS when available
document.getElementById('contact-form').addEventListener('submit', async (e) => {
e.preventDefault()
// ... async submission with error handling
})
</script>
Styling Validation Errors
Apply consistent error styling using a utility class:
.field-error {
display: block;
margin-top: 0.25rem;
font-size: 0.875rem;
color: #dc2626;
}
input:invalid,
textarea:invalid {
border-color: #dc2626;
}
Automated Export Backups
Use the export API endpoints in a scheduled task to create regular backups:
# Download CSV backup of contact form submissions
curl -o "backups/contact-$(date +%Y%m%d).csv" \
https://yoursite.com/api/forms/contact/export/csv
# Download JSON backup
curl -o "backups/contact-$(date +%Y%m%d).json" \
https://yoursite.com/api/forms/contact/export/json
Conditional Fields in Forms
When your blueprint uses visibility conditions, handle them on the frontend by showing/hiding fields based on user input:
const ticketType = document.querySelector('[name="ticket_type"]')
const companyField = document.getElementById('company-group')
ticketType.addEventListener('change', (e) => {
companyField.style.display = e.target.value === 'vip' ? 'block' : 'none'
})
Hidden fields are excluded from the submission payload by the server if their visibility condition evaluates to false, so even if a hidden field is submitted, it won't be stored.