MADORIMADORI

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:

  1. A blueprint that defines the form's fields (what data you collect)
  2. 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}/submit automatically
  • Catches VALIDATION_ERROR responses 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 errors from 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:

  1. The API receives the POST request at /api/forms/{handle}/submit
  2. If honeypot protection is enabled, the _honeypot field is checked — if filled, the submission is silently discarded (returns 201 to avoid revealing detection)
  3. The submission data is validated against the form blueprint's validation rules
  4. If validation passes, the submission is stored as a YAML file in content/forms/{handle}/
  5. 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:

  • id and submitted_at columns 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

  1. A real user submits the form — the _honeypot field is empty
  2. The server checks the honeypot field value
  3. If empty → submission is processed normally
  4. If filled → submission is silently discarded
  5. Both cases return a 201 status 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.