ForgeWP

Build a Native ForgeWP Form

Create a plain React form, declare its server contract, submit it safely, and test it locally.

ForgeWP forms use ordinary React markup. The framework does not require a styled <WpForm> wrapper or a third-party form plugin. You write the fields and layout; ForgeWP generates the server-side contract from wp.config.ts or cms/forms/ and provides submitWpForm() plus the optional useWpForm() state helper.

First choose the form mode

The form contract has two mode values. This is a choice about who handles submission:

Table · 3 columns
Mode
Who owns the form backend?
What ForgeWP generates
native (default)
ForgeWP
A REST endpoint, validation, sanitization, email delivery, optional submission storage, and an admin screen for client-owned fields.
shortcode
A WordPress form plugin
No native endpoint. ForgeWP only lets your React template render the plugin shortcode with <WpShortcode>.

If you are building the form with your own React inputs, use native. If a plugin already owns the form and gives you a shortcode, use shortcode.

This is separate from field ownership. A native form can have developer-owned fields only, or developer-owned fields plus optional client-owned fields managed in WordPress. A shortcode form has no ForgeWP field ownership because the plugin owns the fields.

The decision looks like this:

Flowchart
Rendering diagram...

Scaffold a form

From the theme root, run:

Terminal
pnpm forgewp make:form Contact --fields=name:text,email:email,message:textarea --mailTo=admin

The command creates two files:

Generated form files
cms/
└── forms/
    └── contact.ts
src/
└── components/
    └── forms/
        └── ContactForm.tsx

The form name becomes the REST route segment and must be lowercase kebab-case. If you omit --fields, the scaffold uses name:text, email:email, and message:textarea. Supported field types are text, email, tel, number, textarea, select, and checkbox.

Inspect the generated contract

The generated cms/forms/contact.ts is the server-side source of truth:

cms/forms/contact.ts (generated)
1import { defineWpForm } from "@forgewp/react/config"
2
3export const form = defineWpForm({
4  mailTo: "admin",
5  subject: "New Contact submission",
6  submissionTitle: "Contact — {name}",
7  successMessage: "Thank you. Your request has been received.",
8  notificationBody: "New request from {name} ({email})\n\n{message}",
9  fields: {
10    name: { type: "text", label: "Name", required: true },
11    email: { type: "email", label: "Email", required: true },
12    message: { type: "textarea", label: "Message", required: true },
13  },
14  confirmation: {
15    enabled: true,
16    emailField: "email",
17    subject: "We received your message",
18    body: "Hello {name}, we received your submission.",
19  },
20  storeSubmissions: true,
21})

defineWpForm() is read by the compiler; it is a runtime no-op. mailTo can be admin, a literal address, or an option reference such as option:contact_email. subject, notificationBody, and successMessage provide source defaults; {field} tags in email text are replaced with sanitized submitted values. A blank notificationBody uses the default list of submitted fields.

The confirmation setting is optional and disabled unless enabled: true. Its emailField must name a developer-owned field declared with type: "email". The subject is header-sanitized and the body is sent as plain text. Confirmation uses the configured WordPress wp_mail() transport and does not replace the admin notification. If the confirmation email fails, ForgeWP logs that failure without turning an already stored submission into a visitor-facing error; the visitor-facing success message does not reveal whether the notification email succeeded. SMTP delivery is still configured by the WordPress site, not by ForgeWP.

When storeSubmissions is enabled, submissionTitle optionally names saved entries with a plain-text template such as "Contact — {name}". Tags must reference developer-owned fields; ForgeWP adds the submission time. If the template is omitted or resolves empty, the entry keeps the default form-name-and-time title.

The generated src/components/forms/ContactForm.tsx is a plain interactive React component. Keep its visible field names synchronized with forms.contact.fields; the compiler warns about common mismatches during export.

Submit with submitWpForm

Opt into local contract validation

Pass validateLocal: true to test basic contract rules before the local simulated response. With the ForgeWP Vite runtime enabled, the checks use the form declared in wp.config.ts or cms/forms/, including the current locale's client-field seed.

For the state helper, add it to your existing options:

ContactForm.tsx (useWpForm options)
1const { handleSubmit, errors, message, isSubmitting } = useWpForm("contact", {
2  validateLocal: true,
3  resetOnSuccess: true,
4})

For a direct submission, pass the optional third argument:

src/components/forms/submitContact.ts
1import { submitWpForm, type SubmitWpFormOptions } from "@forgewp/react"
2
3export function submitContact(data: FormData) {
4  const options: SubmitWpFormOptions = { validateLocal: true }
5  return submitWpForm("contact", data, options)
6}

The local checks cover required values (including unchecked required checkboxes), basic email format, and membership in a select field's configured nonempty options list. Errors are returned through the normal WpFormResult.errors map and the hook's existing error state. If you need to exercise these messages instead of the browser's own constraint-validation UI, temporarily use <form noValidate> in your development example.

The default remains the existing success/error simulation. Opted-in validation reports a clear message if the native-form preview contract is unavailable; a component's WpFormFields.fallback is rendering data, not a replacement validation contract. The __fail__ sentinel still works after valid contract checks.

On WordPress, validateLocal does not bypass or replace server validation. Local checks are not a PHP sanitizer or an exact reproduction of WordPress email rules; they do not test length limits, spam protection, permissions, storage, or mail delivery. No real request is sent during local simulation, whether validation is enabled or disabled.

Submit the form data

The generated component uses submitWpForm() from its onSubmit handler:

src/components/forms/ContactForm.tsx
1import * as React from "react"
2import { submitWpForm } from "@forgewp/react"
3
4export default function ContactForm() {
5  const [status, setStatus] = React.useState<
6    "idle" | "sending" | "success" | "error"
7  >("idle")
8  const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>(
9    {}
10  )
11
12  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
13    event.preventDefault()
14    setStatus("sending")
15    setFieldErrors({})
16
17    const formElement = event.currentTarget
18    const result = await submitWpForm("contact", new FormData(formElement))
19
20    if (result.ok) {
21      setStatus("success")
22      formElement.reset()
23    } else {
24      setFieldErrors(result.errors || {})
25      setStatus("error")
26    }
27  }
28
29  return (
30    <form onSubmit={handleSubmit}>
31      <input name="name" required aria-invalid={!!fieldErrors.name} />
32      {fieldErrors.name && <p>{fieldErrors.name}</p>}
33      <input
34        name="email"
35        type="email"
36        required
37        aria-invalid={!!fieldErrors.email}
38      />
39      {fieldErrors.email && <p>{fieldErrors.email}</p>}
40      <textarea name="message" required aria-invalid={!!fieldErrors.message} />
41      {fieldErrors.message && <p>{fieldErrors.message}</p>}
42      <button type="submit" disabled={status === "sending"}>
43        {status === "sending" ? "Sending..." : "Send"}
44      </button>
45      {status === "success" && <p>Thanks. Your message was sent.</p>}
46      {status === "error" && <p>Something went wrong. Please try again.</p>}
47    </form>
48  )
49}

submitWpForm() is a function, not a hook, so it works with plain React state or a form library. Pass either FormData or a plain object. It always resolves to a WpFormResult; network and server failures do not require a surrounding try/catch.

Share submission state with useWpForm

For a reusable form component, useWpForm() centralizes pending, success, error, and field-validation state while calling the same submitWpForm() transport:

src/components/forms/ContactForm.tsx
1import { useWpForm } from "@forgewp/react"
2
3export function ContactForm() {
4  const { handleSubmit, isSubmitting, status, message, errors } =
5    useWpForm("contact")
6
7  return (
8    <form onSubmit={handleSubmit}>
9      <label>
10        Email
11        <input
12          name="email"
13          type="email"
14          required
15          aria-invalid={!!errors.email}
16        />
17      </label>
18      {errors.email && <p role="alert">{errors.email}</p>}
19      <button type="submit" disabled={isSubmitting}>
20        {isSubmitting ? "Sending..." : "Send"}
21      </button>
22      {status === "success" && <p role="status">{message}</p>}
23      {status === "error" && <p role="alert">{message}</p>}
24    </form>
25  )
26}

Use WpFormSubmitButton when forms need a consistent submit-button structure with an optional decorative icon and a pending state:

TSX
1import { WpFormSubmitButton } from "@forgewp/react"
2import { ArrowRight } from "lucide-react"
3
4function SubmitButton({ isSubmitting, showSubmitIcon }) {
5  return (
6    <WpFormSubmitButton
7      icon={<ArrowRight size={16} />}
8      showIcon={showSubmitIcon}
9      pending={isSubmitting}
10      className="inline-flex items-center gap-4"
11    >
12      {isSubmitting ? "Sending…" : "Send message"}
13    </WpFormSubmitButton>
14  )
15}

The component renders a submit button, hides the decorative icon while pending, and disables submission until the pending state ends. It does not add styling; the theme supplies the icon, label, and classes. ForgeWP preserves its button and icon in the exported server-rendered HTML before hydration. A block can expose showSubmitIcon as a boolean attribute with control: "toggle" to provide a sidebar switch.

useWpForm() is useful when the same form component is rendered from a fixed page template and from a Builder block. Pass { resetOnSuccess: true } to clear the form controls after a successful request while keeping the success status/message visible; the default preserves entered values for existing forms. Keep the visitor component as the block's live renderer, and provide a separate inert editorPreview for Gutenberg; the preview must not submit or impersonate a successful request. See Create Your First Gutenberg Block for the editor-preview contract.

Local development and production

The same call behaves differently in each environment:

Table · 2 columns
Environment
Behavior
Local Vite preview
Logs the payload with a [forgewp:forms] prefix, waits ~500ms, and returns simulated success. Entering "__fail__" in any field triggers a mock validation error response.
Exported WordPress theme
Posts to /wp-json/forgewp/v1/forms/<name>/submit and receives a structured success or validation response from PHP.

Testing Error States Offline (__fail__)

During local Vite development, you do not need a mock server to test form failure states. Entering the literal string "__fail__" into any form input causes submitWpForm() to simulate a server-side validation error response:

Simulated Local Error Response
{
  ok: false,
  message: "Simulated validation failure (dev mode)",
  errors: {
    email: "Please enter a valid business email address."
  }
}

This lets you verify your field-level error banners, alert styling, and ARIA announcements before deploying to WordPress.

Built-in Spam Defenses & The 3-Second Time-Trap

ForgeWP provides multi-layered spam defense out of the box without requiring third-party captcha widgets:

  1. The 3-Second Time-Trap (_forgewp_elapsed): Automated bots typically parse a DOM and submit forms within milliseconds. submitWpForm() automatically measures time elapsed since component load (Date.now() - loadedAt). The server silently drops any submission completed in under 3,000 milliseconds (< 3s).
Note

When performing manual QA on a live WordPress site, wait at least 3 seconds before clicking submit, or the test submission will be silently dropped by the bot filter.

  1. Honeypot Input (_forgewp_hp): Forms rendered via <WpFormFields> automatically append an off-screen, visually hidden input. If a bot fills this hidden field, the server silently drops the submission without processing or storing it.
  2. IP Rate Limiting: The server tracks submissions via transient IP hashes ($_SERVER['REMOTE_ADDR']), capping requests to 5 submissions per 10 minutes per IP address.

A form component must be interactive so its onSubmit handler runs in the browser. ForgeWP detects event and state usage to create a hydration island; do not rely on static browser form submission for a native ForgeWP form.

What ForgeWP validates and generates

During export, ForgeWP generates the REST endpoint and PHP handler from the contract. The handler:

  • accepts only declared developer-owned and client-owned field names;
  • sanitizes values by field type and enforces required fields, email validity, lengths, and select options;
  • applies rate limiting, honeypot inspection, and the 3-second time-trap check;
  • sends mail with wp_mail() using the configured recipient and subject;
  • optionally stores submissions in the private forgewp_submission post type;
  • returns field-level errors for the React UI.

The generated theme includes form data in the hydration payload and supplies a logged-in WordPress REST nonce when available. Anonymous visitors are supported; do not add a custom admin-post.php action or assume WpFormFields emits nonce markup. For reading admin-customized labels of developer-owned fields, use useWpFormFieldLabels() (see Client-Owned Fields and WordPress Search).

WordPress admin access and stored submissions

The generated Forms admin menu provides a compact overview with a separate child page for each configured native form with clientFields.enabled; it does not create native forms or plugin forms. Core fields declared in fields keep their developer-owned names, types, required validation, and server-side allowlist, but their displayed labels can be edited in WordPress. clientFields.enabled adds optional editor-managed questions that can be added, removed, reordered, and configured. Their stable machine keys are used for validation and stored submissions; changing a displayed label does not rename the key. The configured recipient and other delivery/security behavior remain developer-owned.

For forms with clientFields.enabled, the same screen also lets authorized admins edit the success message, notification email subject/body, and (when configured) submitter receipt email subject/body. Templates are plain text; use {field_name} for sanitized values. They are interpolated as text and never evaluated as PHP or HTML. These settings are saved in WordPress options and survive theme re-exports; the values in cms/forms/<name>.ts are defaults for a fresh installation. Keep SMTP settings in the mail plugin/WordPress host, not in the form editor.

The screen and the private forgewp_submission content type use the same Forms capability. By default that capability is manage_options. A theme can opt into a different WordPress capability at the root of wp.config.ts:

TS
1export default defineConfig({
2  // Other theme configuration...
3  formsAdminCapability: "edit_pages",
4})

Only use a lower-privilege capability when its users are allowed to read submitted personal data. The setting grants access to both field management and all stored submissions; it does not provide separate field-editor and inbox permissions. Existing configs omit the setting and retain the manage_options default.

ForgeWP uses this value as a primitive capability for the submissions post type, without registering it as a global meta-capability alias. This keeps the default manage_options capability—and other configured capabilities—from affecting unrelated WordPress admin screens.

Stored entries appear in the WordPress Form Submissions screen when storeSubmissions is enabled (the default). The screen includes an Export CSV action. The administrator chooses a form/date range and explicitly selects the personal-data columns; no fields are selected by default. The export is capability-protected, nonce-checked, UTF-8 encoded, excludes IP hashes, and neutralizes spreadsheet formula-leading values. For an all-forms export, columns are labeled with their form so duplicate field names remain distinguishable. Use submissionTitle when the default form-name-and-time title is not enough to recognize an entry in the admin list.

ForgeWP registers WordPress personal-data exporter and eraser callbacks for stored submissions. New entries store a site-salted HMAC of each valid email field so a privacy request can still find an entry if a client-managed email field is later renamed or removed. Older entries are also matched through currently declared email fields. The HMAC is not included in CSV or privacy exports. Entries without a stored email identifier or currently declared email field cannot be matched through these callbacks and require a separately defined identifier workflow.

Retention is opt-in per stored form:

TS
1forms: {
2  contact: {
3    // Existing mailTo and fields...
4    retentionDays: 90,
5  },
6}

When configured, WordPress schedules a daily WP-Cron task that permanently deletes that form’s submissions after the specified number of days. Omit retentionDays to retain submissions indefinitely; set it only after agreeing the retention period and deletion policy. This version has no legal-hold exemption, so do not enable it for records that must be retained under a hold. WP-Cron scheduling and privacy/export flows still require verification on the actual WordPress host.

Native confirmations remain opt-in using confirmation; they are separate from the admin notification. Plugins may still be a better fit for specialized workflows such as approval, capacity management, waitlists, or event-specific exports.

If wp_mail() fails after a submission was stored, the endpoint still returns success with the configured success message so the visitor is not prompted to submit the same personal data again; the mail failure is logged server-side. If storage is disabled and mail fails, the endpoint returns an error.

Exported output

Run:

Terminal
pnpm export

The generated theme contains the form endpoint and, when storeSubmissions is enabled, a private Form Submissions admin screen. The form source remains cms/forms/contact.ts; generated PHP and hydration assets are build output.

Use a plugin-managed form instead

Some projects already use a WordPress form plugin such as WPForms, Contact Form 7, or Gravity Forms. In that case, let the plugin own the fields, validation, notifications, and submission handling. Declare the form as shortcode so ForgeWP does not generate a second native endpoint for it.

First create a shortcode-mode contract:

cms/forms/newsletter.ts
1import { defineWpForm } from "@forgewp/react/config"
2
3export const form = defineWpForm({
4  mode: "shortcode",
5  shortcode: '[wpforms id="12" title="Newsletter Signup"]',
6})

Shortcode mode is intentionally a smaller contract. Do not copy native-only options such as mailTo, fields, clientFields, or storeSubmissions into it; those options belong to ForgeWP native mode and are ignored for plugin-managed forms.

Then render the plugin shortcode from a normal theme component:

src/components/NewsletterForm.tsx
1import { WpShortcode } from "@forgewp/react"
2
3export function NewsletterForm() {
4  return (
5    <section>
6      <h2>Join the newsletter</h2>
7      <WpShortcode code={'[wpforms id="12" title="Newsletter Signup"]'} />
8    </section>
9  )
10}

In this mode, WpShortcode becomes WordPress's do_shortcode() during export. ForgeWP does not generate the REST route, mail delivery, submission storage, or client-field editor for this form. Do not call submitWpForm("newsletter", ...) or render <WpFormFields form="newsletter" />; those APIs belong to native ForgeWP forms.

The shortcode value in cms/forms/newsletter.ts documents the default shortcode and validates the contract. If the shortcode itself should be editable in WordPress, read it from a page/meta field with useWpMeta() and pass that value to WpShortcode instead. The plugin still owns the resulting markup and behavior.

Let an editor place a plugin form in WordPress content

When an editor creates a new form inside a WordPress form plugin, they can copy that form's shortcode and insert it with WordPress's core Shortcode block in a Page or CPT post whose template renders its content with <BlockArea />. WordPress processes the saved block through the_content(), and the active plugin handles the shortcode. The editor does not need a new forms entry in wp.config.ts for a shortcode saved directly in post content.

This differs from mode: 'shortcode' plus <WpShortcode>: that path is for a shortcode authored in a ForgeWP React component and declared by the developer. In local Vite preview, <WpShortcode> displays a placeholder; it cannot execute a WordPress plugin. During export, it emits a compiler marker that becomes do_shortcode() in the generated WordPress template. Test the actual plugin shortcode in the exported theme on a WordPress site with the plugin active.

Note

Use native ForgeWP mode when you want React-controlled markup and ForgeWP-generated validation and delivery. Use shortcode mode when the WordPress plugin is the source of truth for the form.

Reuse named email templates

Keep shared email defaults in plain JSON files under cms/mail/. Create one from the theme root:

Terminal
pnpm forgewp make:mail contact-receipt

Edit the generated file:

cms/mail/contact-receipt.json
1{
2  "subject": "Thanks for contacting us, {name}",
3  "body": "Hello {name},\nWe have received your message."
4}

Reference its filename without the extension in a native form:

cms/forms/contact.ts
1import { defineWpForm } from "@forgewp/react/config"
2
3export default defineWpForm({
4  mailTo: "admin",
5  fields: {
6    name: { type: "text", required: true },
7    email: { type: "email", required: true },
8  },
9  notificationTemplate: "contact-receipt",
10  confirmation: {
11    enabled: true,
12    emailField: "email",
13    template: "contact-receipt",
14  },
15  clientFields: { enabled: true, seed: [] },
16})

Use different template names when the staff notification and visitor receipt need different messages. A template supplies both subject and body. Explicit subject and notificationBody values override notification defaults; explicit confirmation subject and body override receipt defaults.

Admins manage the resulting text at Forms ? select a form ? Form messages and labels when client field management is enabled. Saved admin text takes priority over source defaults; changing the JSON file does not replace those saved edits. The source files remain developer-managed and are resolved during config loading/export. Local preview still simulates delivery.

Template names use lowercase letters, numbers, and hyphens, beginning with a letter. Referenced files must contain string subject and body properties. Missing files, invalid JSON, or undeclared placeholders in a referenced template stop config loading with an explanation. Notification templates may reference developer-owned fields and configured client seed fields. Receipt templates reference developer-owned fields only. Use {name} for a declared field; optional values absent at submission become empty text. Templates are plain text data: PHP, HTML, and JavaScript in the message are not executed as template code. Existing WordPress sanitization and subject header protection still apply. At submission time, a valid optional field with no value becomes empty text.

Placeholder checks when authoring and editing

Placeholder names match field keys exactly and are case-sensitive. If a form declares email, use {email}; {mail} and {Email} are different names.

Inline notification subject and notificationBody are checked during export, just like resolved notification defaults: allowed keys come from developer-owned fields and enabled client-field seeds, including per-locale seeds. Receipt subject and body use developer-owned field keys only. An undeclared placeholder produces an export error identifying the form, setting, and field.

The WordPress form settings screen lists allowed placeholders. On save, notification text is checked against developer fields and the currently saved client fields across configured locales; receipt text is checked against developer fields only. Unknown placeholders reject the entire settings save and show the unknown names and allowed keys. Existing settings remain intact. Use the back link to correct the message and save again. Removing a client field can make an older notification placeholder invalid; update that message before saving settings again. Existing saved messages are not automatically rewritten.