Skip to content

04 — Form Module

Create a form module with validation, submission, and CMS-driven field configuration.
Example: M17 Form Module (contact, newsletter, enquiry, careers).

Use alongside template 01 — Module Development for form modules.
Forms are always Interactive (Client Component) — template 01 handles the base structure.


Read these files before executing the prompt:

docs/PRD/12_Forms_and_Data_Capture.md
docs/PRD/08_API_Contracts_and_Integration.md
docs/dev-frontend-guides/03_MODULE_DEVELOPMENT_LIFECYCLE.md
docs/dev-frontend-guides/05_BEM_SASS_THEMING.md
docs/PRD/07_Modules_and_Templates.md
docs/PRD/14_Accessibility_and_Compliance.md
packages/modules/src/registry.ts
packages/ui/src/ # scan for form-related UI components

Read the following context files first:
- docs/PRD/12_Forms_and_Data_Capture.md
- docs/PRD/08_API_Contracts_and_Integration.md
- docs/dev-frontend-guides/03_MODULE_DEVELOPMENT_LIFECYCLE.md
- docs/dev-frontend-guides/05_BEM_SASS_THEMING.md
- docs/PRD/07_Modules_and_Templates.md
- docs/PRD/14_Accessibility_and_Compliance.md
- packages/modules/src/registry.ts
- packages/ui/src/ (scan for form-related UI components: Input, Select, Textarea, Checkbox, FileUpload, Button)
Now create a new **Form Module** with the following details:
**Module ID:** M{MODULE_NUMBER}
**Module Name:** {MODULE_NAME}
**Figma Reference:** {FIGMA_URL}
**Umbraco Content Type Alias:** {UMBRACO_ALIAS}
**Description:**
{BRIEF_DESCRIPTION_OF_FORM_PURPOSE}
**Form Type:** {FORM_TYPE — contact | newsletter | enquiry | careers | custom}
**Fields Specification:**
| Field Name | Type | Required | Validation | Placeholder/Label |
|------------|------|----------|------------|-------------------|
| {FIELD_NAME} | {text/email/tel/textarea/select/checkbox/file/hidden} | {yes/no} | {RULES} | {LABEL_TEXT} |
Example row:
| email | email | yes | valid email format, max 254 chars | "Email Address" |
**Validation Rules:**
{DESCRIBE_SPECIAL_VALIDATION — e.g., phone format per locale, file size limits, conditional required fields}
**Submission Endpoint:**
- **URL:** {API_ENDPOINT — e.g., /api/forms/contact}
- **Method:** POST
- **Content-Type:** {application/json or multipart/form-data for file uploads}
- **Response:** { success: boolean; message?: string; errors?: Record<string, string> }
**Success State:**
{WHAT_HAPPENS_ON_SUCCESS — e.g., show confirmation message, redirect to thank-you page, close modal}
**Error State:**
{WHAT_HAPPENS_ON_ERROR — e.g., inline field errors, summary at top, toast notification}
**File Upload (if applicable):**
- Accepted types: {FILE_TYPES — e.g., .pdf, .doc, .docx}
- Max file size: {SIZE — e.g., 5MB}
- Max files: {COUNT — e.g., 3}
**Email Routing:**
{HOW_SUBMISSION_IS_ROUTED — e.g., department dropdown determines recipient, fixed address, per-site routing}
Create all required files:
1. packages/modules/src/m{MODULE_NUMBER}-{KEBAB_NAME}/index.tsx (Server Component wrapper)
2. packages/modules/src/m{MODULE_NUMBER}-{KEBAB_NAME}/m{MODULE_NUMBER}-{KEBAB_NAME}.client.tsx ('use client' — form logic)
3. packages/modules/src/m{MODULE_NUMBER}-{KEBAB_NAME}/{PascalName}.scss
4. packages/modules/src/m{MODULE_NUMBER}-{KEBAB_NAME}/m{MODULE_NUMBER}-{KEBAB_NAME}.types.ts
5. packages/modules/src/m{MODULE_NUMBER}-{KEBAB_NAME}/m{MODULE_NUMBER}-{KEBAB_NAME}.schema.ts (zod validation schema)
6. packages/modules/src/m{MODULE_NUMBER}-{KEBAB_NAME}/m{MODULE_NUMBER}-{KEBAB_NAME}.mapper.ts
7. packages/modules/src/m{MODULE_NUMBER}-{KEBAB_NAME}/m{MODULE_NUMBER}-{KEBAB_NAME}.stories.tsx
8. packages/modules/src/m{MODULE_NUMBER}-{KEBAB_NAME}/m{MODULE_NUMBER}-{KEBAB_NAME}.test.tsx
Then register the module in packages/modules/src/registry.ts.
Follow these form-specific conventions:
- Use react-hook-form for form state management
- Use zod for schema validation (define in .schema.ts, use zodResolver)
- Dynamic form rendering: CMS provides field configuration, component renders fields dynamically
- Honeypot anti-spam: include a hidden field named 'website' (visually hidden, not type="hidden")
- reCAPTCHA v3: execute on submit, send token with form data
- CSRF token: fetch from /api/csrf endpoint before submission, include in headers
- Server-side validation: always validate on server too, display server errors inline
- GDPR consent: mandatory checkbox, form cannot submit without it
- Accessible error messages: aria-describedby linking field to error, aria-invalid on invalid fields
- Focus first error field on failed validation
- Loading state on submit button (disabled + spinner)
- All standard module conventions: BEM + SASS, CSS variables, mapper, stories, tests, registry

  • All 8 files created in packages/modules/src/m{XX}-{kebab-name}/
  • 'use client' directive in .client.tsx only
  • index.tsx is a Server Component wrapper
  • react-hook-form used for form state (useForm with zodResolver)
  • zod schema defined in .schema.ts matching all field validation rules
  • All fields render dynamically based on CMS field configuration
  • Honeypot field present: field named website, visually hidden with CSS (not display: none or type="hidden"), SR-only label
  • reCAPTCHA v3 integration: token generated on submit, sent in form payload
  • CSRF token fetched and included in submission headers
  • GDPR consent checkbox is mandatory — form cannot submit without it
  • Client-side validation: zod schema validates on blur and on submit
  • Server-side validation: API errors mapped back to individual fields inline
  • Error display: aria-invalid="true" on invalid fields, aria-describedby links to error message element
  • Focus management: on validation failure, focus moves to first field with error
  • Submit button shows loading state (disabled + visual indicator) during submission
  • Success state renders correctly (message, redirect, or modal close as specified)
  • Error state renders correctly (inline errors, summary, or toast as specified)
  • File upload (if applicable): validates type, size, and count; shows filename after selection; allows removal
  • Storybook stories: empty form, partially filled, all errors shown, success state, loading state
  • Unit tests: schema validation (valid data, each invalid field), submit success, submit error, honeypot detection
  • BEM class naming, CSS custom properties, responsive layout
  • Module registered in packages/modules/src/registry.ts
  • No TypeScript errors
  • Form is fully operable with keyboard only
  • Screen reader can navigate all fields, labels, errors, and states

  1. Honeypot as type="hidden" — Bots skip type="hidden" fields. The honeypot must be a real text input, visually hidden with CSS (position: absolute; left: -9999px; opacity: 0), with a label for screen readers that says “Do not fill this field”.
  2. Missing aria-describedby on error — Each field’s error message needs an id, and the field needs aria-describedby pointing to that id. Without this, screen readers do not announce errors.
  3. Not focusing first error — Sighted keyboard users and screen reader users need focus to move to the first invalid field on submit. Use setFocus from react-hook-form.
  4. GDPR checkbox not blocking submit — The consent checkbox must be in the zod schema as z.literal(true) with a custom error message, not just z.boolean().
  5. reCAPTCHA loaded eagerly — Load the reCAPTCHA script lazily (only when form is visible or on first interaction) to avoid blocking page load.
  6. File upload with application/json — If the form has file uploads, Content-Type must be multipart/form-data, not application/json. Use FormData object.
  7. No loading state on double-click — Disable the submit button immediately on click to prevent duplicate submissions. Re-enable on error.
  8. Server errors not mapped to fields — The API returns errors: { email: "already registered" }. These must be mapped to individual field errors using setError from react-hook-form, not just shown as a generic message.
  9. Missing noValidate on &lt;form&gt; — Add noValidate to the &lt;form&gt; tag to disable browser native validation and use your custom validation UX instead.
  10. Forgetting error summary for long forms — If the form has more than 5 fields, add an error summary at the top of the form listing all errors with anchor links to each field.

Filled-in prompt for Contact Us Form:

Read the following context files first:
- docs/PRD/12_Forms_and_Data_Capture.md
- docs/PRD/08_API_Contracts_and_Integration.md
- docs/dev-frontend-guides/03_MODULE_DEVELOPMENT_LIFECYCLE.md
- docs/dev-frontend-guides/05_BEM_SASS_THEMING.md
- docs/PRD/07_Modules_and_Templates.md
- docs/PRD/14_Accessibility_and_Compliance.md
- packages/modules/src/registry.ts
- packages/ui/src/ (scan for form-related UI components)
Now create a new **Form Module** with the following details:
**Module ID:** M17
**Module Name:** Form Module
**Figma Reference:** https://www.figma.com/design/abc123/Savoy-Design-System?node-id=4567-8901
**Umbraco Content Type Alias:** formModule
**Description:**
Generic form module that renders different form types based on CMS configuration.
This instance is the Contact Us form used on every hotel site's contact page. Collects
visitor name, email, selects the relevant department, and sends a message. Department
selection determines email routing on the backend.
**Form Type:** contact
**Fields Specification:**
| Field Name | Type | Required | Validation | Placeholder/Label |
|------------|------|----------|------------|-------------------|
| firstName | text | yes | min 1, max 50 chars | "First Name" |
| lastName | text | yes | min 1, max 50 chars | "Last Name" |
| email | email | yes | valid email, max 254 chars | "Email Address" |
| department | select | yes | must be one of predefined values | "Select Department" |
| message | textarea | yes | min 10, max 2000 chars | "Your Message" |
| gdprConsent | checkbox | yes | must be true | "I agree to the Privacy Policy and consent to my data being processed" |
| website | text | no | honeypot — must be empty | "Website" (hidden) |
Department options (from CMS): General Enquiry, Reservations, Events & Conferences,
Spa & Wellness, Dining, Careers.
**Validation Rules:**
- firstName/lastName: trim whitespace, alphanumeric + spaces + hyphens + apostrophes
- email: standard email regex + max length
- department: must match one of the CMS-provided option values
- message: minimum 10 characters to reduce spam, maximum 2000
- gdprConsent: must be true (z.literal(true))
- website (honeypot): must be empty string — if filled, silently reject but return fake success
**Submission Endpoint:**
- **URL:** /api/forms/contact
- **Method:** POST
- **Content-Type:** application/json
- **Payload:** { firstName, lastName, email, department, message, gdprConsent, recaptchaToken, siteKey }
- **Response:** { success: boolean; message?: string; errors?: Record<string, string> }
**Success State:**
Hide the form and show a confirmation panel with heading "Thank You" and message
"We've received your message and will respond within 48 hours." Include a "Send Another"
button that resets and shows the form again.
**Error State:**
On validation error: inline error below each field, focus first error field.
On server error (500): show a banner above the form: "Something went wrong. Please try
again or contact us at {hotel email}."
On rate limit (429): show "Too many requests. Please wait a moment and try again."
**Email Routing:**
The department field value is sent to the API. The backend uses it to route the email
to the correct department address for the specific hotel site (determined by siteKey).
Create all required files:
1. packages/modules/src/m17-form-module/index.tsx
2. packages/modules/src/m17-form-module/m17-form-module.client.tsx
3. packages/modules/src/m17-form-module/FormModule.scss
4. packages/modules/src/m17-form-module/m17-form-module.types.ts
5. packages/modules/src/m17-form-module/m17-form-module.schema.ts
6. packages/modules/src/m17-form-module/m17-form-module.mapper.ts
7. packages/modules/src/m17-form-module/m17-form-module.stories.tsx
8. packages/modules/src/m17-form-module/m17-form-module.test.tsx
Register in packages/modules/src/registry.ts.
Form-specific requirements:
- react-hook-form + zodResolver with schema from .schema.ts
- Honeypot: 'website' field, visually hidden, silently reject if filled
- reCAPTCHA v3: lazy-load script, execute on submit, include token
- CSRF: fetch token from /api/csrf, include in X-CSRF-Token header
- GDPR consent: z.literal(true), custom error "You must agree to the Privacy Policy"
- Focus first error on failed validation
- Submit button: "Send Message", disabled + spinner while submitting
- noValidate on <form> element
- All field labels associated via htmlFor/id
- aria-invalid and aria-describedby on fields with errors
- BEM + SASS, CSS custom properties, responsive, all 8 themes