Forms are the backbone of user interaction on the web — contact forms, job applications, newsletter signups, surveys, and more. Wagtail's Form Builder provides a powerful, editor-friendly way to create and manage forms entirely through the admin panel, with automatic submission storage and CSV export. In this lesson, you'll build a contact form from scratch, learn how form submissions work under the hood, and get hands-on with email notifications and custom validation.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Understand Wagtail's built-in form infrastructure —
AbstractFormField,AbstractForm, andAbstractEmailForm - Create a custom FormPage model with multiple field types (text, email, dropdown, checkboxes, radio buttons)
- Collect, view, and export form submissions from the Wagtail admin panel
- Implement email notifications for new form submissions
- Handle file uploads through Wagtail forms
- Customize form processing with server-side validation and business logic
2. Why This Matters (Real-World Scenario)
Imagine you are building a corporate website for a consulting firm. They need a contact form that routes inquiries to the right department, a job application form that accepts resume uploads, and a newsletter signup form — all managed by the marketing team without developer intervention. With Wagtail's Form Builder, non-technical editors can create, modify, and manage forms entirely from the Wagtail admin, while submissions are automatically stored and can be exported as CSV for analysis. This lesson shows you how to build this capability from scratch.
3. Core Concepts
Wagtail provides the wagtail.contrib.forms module, which gives you the building blocks for creating any type of form page. Understanding these base classes is essential.
3.1 AbstractFormField
A Django model that defines a single form field — its label, type (text, email, dropdown, etc.), whether it's required, and its choices (for dropdown/radio/checkbox fields). Each form page has many form fields, linked via a ParentalKey.
3.2 AbstractForm / AbstractEmailForm
Page models that include form processing logic. AbstractForm stores submissions in the database. AbstractEmailForm extends this and also sends email notifications. Both provide the serve() method that handles GET (display form) and POST (process submission) requests.
3.3 FormField Types
Wagtail's built-in field types include: singleline, multiline, email, number, url, checkbox, checkboxes, dropdown, radio, date, datetime, and hidden. Each maps to a Django form field widget and can be added through the Wagtail admin by non-technical editors.
3.4 Form Submissions
Every submission is stored as a FormSubmission model instance, which records the form page, submission date, and the form data as JSON. The Wagtail admin includes a built-in interface for browsing, filtering, and exporting submissions as CSV files. You can also access submissions programmatically via the Django ORM using the FormSubmission.objects.filter(page=...) queryset.
4. Hands-On Practice
Let's build a fully functional contact form page step by step.
4.1 Create the FormPage Model
Start by defining a page model that inherits from AbstractEmailForm. This model handles form rendering, validation, submission storage, and email notifications.
# models.py - Contact form page model
from django.db import models
from modelcluster.fields import ParentalKey
from wagtail.models import Page
from wagtail.contrib.forms.models import AbstractEmailForm, AbstractFormField
from wagtail.admin.panels import (
FieldPanel, InlinePanel, MultiFieldPanel, PageChooserPanel
)
from wagtail.fields import RichTextField
from wagtail.contrib.forms.panels import FormSubmissionsPanel
class FormField(AbstractFormField):
page = ParentalKey(
'ContactFormPage',
on_delete=models.CASCADE,
related_name='form_fields'
)
class ContactFormPage(AbstractEmailForm):
intro = RichTextField(blank=True)
thank_you_text = RichTextField(blank=True)
content_panels = AbstractEmailForm.content_panels + [
FormSubmissionsPanel(),
FieldPanel('intro'),
InlinePanel('form_fields', label='Form Fields'),
FieldPanel('thank_you_text'),
MultiFieldPanel([
FieldPanel('to_address'),
FieldPanel('from_address'),
FieldPanel('subject'),
], heading='Email Settings'),
]
parent_page_types = ['home.HomePage']
subpage_types = []
4.2 Create the Template
Wagtail expects a template named after the model: contact_form_page.html. The form context variable already contains the Django Form instance. Remember to include enctype="multipart/form-data" if your form accepts file uploads.
<!-- contact_form_page.html -->
{% extends 'base.html' %}
{% load wagtailcore_tags %}
{% block content %}
<h1>{{ page.title }}</h1>
<div>{{ page.intro|richtext }}</div>
<form action="{% pageurl page %}" method="POST" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Submit">
</form>
{% endblock %}
4.3 Register Page and Migrate
Register the model if needed, then create and run database migrations:
# Create and run migrations
python manage.py makemigrations
python manage.py migrate
# Restart the development server
python manage.py runserver
4.4 Add Form Fields in Wagtail Admin
Now navigate to the Wagtail admin, create a new ContactFormPage, and add form fields via the inline panel. Follow these steps:
- Go to Wagtail admin > Pages > Create new page > Contact Form Page
- Enter a title (e.g., "Contact Us") and fill in the introductory text
- Under Form Fields, add fields like: Name (singleline, required), Email (email, required), Subject (dropdown with choices), Message (multiline, required)
- Configure email settings: set "To Address" to your email, "From Address" to the site address, and a subject prefix
- Write a thank-you message in the Thank You Text field
- Publish the page
$ python manage.py runserver
Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
April 09, 2025 - 10:30:15
Django version 5.2, using settings 'mysite.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
4.5 Viewing Submissions
Once users submit the form, you can view submissions directly in the Wagtail admin. Open the Contact Us page in the page editor and click the Submissions tab (added by FormSubmissionsPanel). From here you can filter, inspect, and export submissions as CSV. The Wagtail admin provides a "Download CSV" button that exports all submissions for a given form page as a comma-separated file — perfect for analysis in spreadsheets or data tools.
$ python manage.py shell
>>> from wagtail.contrib.forms.models import FormSubmission
>>> submissions = FormSubmission.objects.filter(page__title='Contact Us')
>>> for s in submissions:
... print(s.submit_time, s.form_data)
2025-04-09 10:45:00 {'name': 'John Doe', 'email': 'john@example.com', 'message': 'Hello!'}
2025-04-09 11:00:00 {'name': 'Jane Smith', 'email': 'jane@example.com', 'message': 'Question about services'}
4.6 Email Notifications
Since our model inherits from AbstractEmailForm, emails are sent automatically when a form is submitted. The email includes all form fields and their values. Configure the to_address, from_address, and subject fields in the Wagtail admin page editor. On the Django side, ensure your email backend is configured in settings.py.
# settings.py - Email configuration
# Development (prints emails to console)
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Production (SMTP example)
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
# EMAIL_HOST = 'smtp.gmail.com'
# EMAIL_PORT = 587
# EMAIL_USE_TLS = True
# EMAIL_HOST_USER = 'your@email.com'
# EMAIL_HOST_PASSWORD = 'your-app-password'
5. Common Errors & Solutions
Here are frequent issues you may encounter and how to resolve them.
5.1 Submissions Tab Not Visible
Error: The Submissions panel does not appear in the page editor.
Solution: The FormSubmissionsPanel must be added to content_panels before other panels. Also ensure your model inherits from AbstractForm or AbstractEmailForm, not just Page.
# Correct placement of FormSubmissionsPanel
content_panels = AbstractEmailForm.content_panels + [
FormSubmissionsPanel(), # First!
FieldPanel('intro'),
InlinePanel('form_fields', label='Form Fields'),
]
5.2 Emails Not Sent
Error: Form submits successfully, but no email is delivered.
Solution: Configure Django's email backend in settings.py. For development, use the console backend; for production, set up SMTP. Also verify the to_address field in the page editor is populated with a valid email address.
5.3 File Uploads Not Working
Error: File input fields do not save uploaded files.
Solution: Ensure your form template includes enctype="multipart/form-data" in the <form> tag. Also configure MEDIA_ROOT and MEDIA_URL in your Django settings.
5.4 Field Choices Not Appearing in Dropdown
Error: A dropdown field renders as a text input instead of a select menu.
Solution: Wagtail's dropdown type requires the Choices field to be populated with comma-separated values. Use the format: Sales, Support, Billing, General. Each option becomes a dropdown <option> element.
5.5 Cannot Access Submissions in Admin
Error: 404 or permission denied when viewing submissions.
Solution: Ensure the user has the appropriate permissions. Grant the user the Can access Wagtail admin permission and add them to a group with Can view form submissions for the relevant page type.
6. Summary Checklist
Here is a checklist to confirm you have completed the lesson successfully:
- Created a page model inheriting from
AbstractEmailForm - Defined a
FormFieldmodel withParentalKeylinking to the form page - Added
FormSubmissionsPanelto content_panels for admin access - Created a template with proper form action, CSRF token, and enctype
- Configured email backend in Django settings
- Created and applied database migrations
- Tested form submission from the live page
- Verified submissions appear in the Wagtail admin
- Exported submissions as CSV for external analysis
7. Practice Exercise
Challenge: Build a Job Application form page that includes the following fields:
- Full Name (singleline, required)
- Email Address (email, required)
- Position Applying For (dropdown: Developer, Designer, Marketing, Sales)
- Cover Letter (multiline, required)
- Resume Upload (file upload, required)
- How Did You Hear About Us? (radio: LinkedIn, Referral, Job Board, Other)
- Available Start Date (date)
Requirements:
- The form should email the hiring team when submitted
- Uploaded resumes should be stored and accessible from the admin
- A custom thank-you page should display after successful submission
- Bonus: Override the
serve()method to add custom validation (e.g., reject applications submitted outside business hours)
# Bonus: Custom validation example
from django.shortcuts import render
from datetime import datetime
class JobApplicationPage(AbstractEmailForm):
# ... fields omitted for brevity ...
def serve(self, request, *args, **kwargs):
if request.method == 'POST':
now = datetime.now()
if now.hour < 9 or now.hour >= 17:
form = self.get_form(request.POST, request.FILES)
return render(request, self.template, {
'page': self,
'form': form,
'error_message': 'Applications accepted only during business hours (9 AM - 5 PM).'
})
return super().serve(request, *args, **kwargs)
8. Next Steps
Congratulations! You now know how to build custom forms with Wagtail's Form Builder and manage submissions. Here's what to explore next:
- Next Lesson: Wagtail Multi-language & Internationalization: Building Multi-lingual Sites — learn how to expand your site across languages using Wagtail's i18n features
- Explore
wagtail.contrib.forms.forms.FormBuilderto build forms programmatically outside the page model - Customize form submission processing by overriding the
process_form_submissionmethod - Integrate form data with external CRM systems using webhooks or Celery tasks
- Use Django's built-in spam protection with HoneypotField or reCAPTCHA
$ python manage.py runserver
[10/Apr/2025 14:22:31] "POST /contact-us/" 302 0
[10/Apr/2025 14:22:31] "GET /contact-us/thank-you/" 200 250
Email notification sent to: hr@company.com
Submission saved (ID: 42)
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment