Forms
Dune includes a blueprint-driven form system. You define forms as YAML files and the engine handles validation, spam protection, file uploads, email notifications, and submission storage — no backend code required.
Creating a form
Create a YAML file in the forms/ directory at your site root:
forms/
contact.yaml
job-application.yaml
newsletter.yaml
Each file defines one form. The filename (without extension) is the form's identifier.
Basic example
# forms/contact.yaml
title: "Contact Us"
success_url: "/contact/thanks"
honeypot: _email_confirm # hidden field — bots fill it, humans don't
fields:
name:
type: text
label: "Your name"
required: true
minlength: 2
email:
type: email
label: "Email address"
required: true
subject:
type: select
label: "Subject"
required: true
options:
general: "General enquiry"
support: "Technical support"
billing: "Billing question"
message:
type: textarea
label: "Message"
required: true
minlength: 20
maxlength: 5000
With notifications
# forms/contact.yaml
title: "Contact Us"
success_url: "/thanks"
notifications:
email: "hello@example.com" # override To: for this form only
webhook: "https://hooks.example.com/contact" # POST JSON payload
fields:
# ...
The notifications.email value overrides the To: address for this specific form — the global SMTP configuration (host, port, credentials) is still used. Configure global SMTP in your admin settings.
Field types
| Type | Description |
|---|---|
text |
Single-line text input |
email |
Email address (validated format) |
tel |
Phone number |
textarea |
Multi-line text |
number |
Numeric input |
select |
Dropdown — requires options map |
checkbox |
Boolean on/off |
file |
File upload |
hidden |
Hidden field with a fixed value |
Field options
| Option | Types | Description |
|---|---|---|
label |
all | Human-readable label |
required |
all | Field must be present and non-empty |
minlength |
text, email, tel, textarea | Minimum character count |
maxlength |
text, email, tel, textarea | Maximum character count |
min |
number | Minimum numeric value |
max |
number | Maximum numeric value |
pattern |
text, email, tel | Regex the value must match |
options |
select | Map of value: label pairs |
value |
hidden | Fixed value for hidden fields |
The API endpoint
Each form in forms/ is automatically served at two endpoints:
| Method | URL | Description |
|---|---|---|
GET |
/api/forms/{name} |
Returns the public form schema as JSON |
POST |
/api/forms/{name} |
Submits the form |
GET — fetch schema
GET /api/forms/contact
{
"title": "Contact Us",
"success_url": "/thanks",
"honeypot": "_email_confirm",
"fields": {
"name": { "type": "text", "label": "Your name", "required": true, "minlength": 2 },
"email": { "type": "email", "label": "Email address", "required": true },
"message": { "type": "textarea", "label": "Message", "required": true }
}
}
Sensitive keys (notifications.email, notifications.webhook) are omitted from the public schema.
POST — submit
Send multipart/form-data (required for file uploads) or application/x-www-form-urlencoded:
<form method="POST" action="/api/forms/contact" enctype="multipart/form-data">
<input type="text" name="name" required>
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<!-- Honeypot: hide this with CSS, don't remove it -->
<input type="text" name="_email_confirm" style="display:none" tabindex="-1" autocomplete="off">
<button type="submit">Send</button>
</form>
Success response (303 See Other):
Location: /thanks
Validation error response (422 Unprocessable Entity):
{
"errors": [
{ "field": "email", "message": "Invalid email address" },
{ "field": "message", "message": "Must be at least 20 characters" }
]
}
Rate limit response (429 Too Many Requests):
{ "error": "Too many submissions — please wait before trying again" }
Accessing submissions
All submissions are stored in admin.dataDir (default data/) and are viewable from the admin panel under Form Submissions. Each submission records:
- Timestamp
- IP address (hashed for privacy)
- All submitted field values
- Any uploaded file references
Submissions are stored as JSON in data/submissions/{form-name}/. This directory is git-tracked, so submissions are preserved across deploys.
Spam protection
Dune applies two spam prevention layers:
Honeypot field: Add a honeypot key to your form definition. The engine generates a hidden field with that name. Automated bots typically fill every field — if the honeypot field is non-empty, the submission is silently rejected (returns success to avoid training bots).
Rate limiting: A per-IP rate limit is applied to all form submissions. By default, submissions from the same IP are limited to a few requests per minute.
File uploads
Add a file field to accept attachments:
fields:
resume:
type: file
label: "Upload your CV"
required: true
Uploaded files are stored in admin.dataDir/uploads/ and referenced from the submission record by filename. The admin panel displays uploaded files with download links.
Note: There is currently no built-in file size or type restriction in the form schema — apply restrictions at the infrastructure level (reverse proxy upload size limits) or validate in a
onApiRequesthook.
Using forms with JavaScript
For single-page applications or fetch-based submissions, you can use the JSON API directly:
// Fetch the form schema
const schema = await fetch("/api/forms/contact").then(r => r.json());
// Build FormData from user input
const body = new FormData();
body.append("name", "Jane Smith");
body.append("email", "jane@example.com");
body.append("message", "Hello from the JS client!");
// Submit
const res = await fetch("/api/forms/contact", { method: "POST", body });
if (res.status === 303) {
// Success — redirect to success_url or show inline message
window.location.href = schema.success_url ?? "/";
} else if (res.status === 422) {
const { errors } = await res.json();
// Display field errors
}
Disabling a form
Remove the YAML file to disable a form entirely — requests to its endpoint will return 404. There is no enabled: false toggle; the presence of the file is the gate.