ForgeWP

Client-Owned Fields and WordPress Search

Let site editors manage selected form fields and build search interfaces with native WordPress queries.

ForgeWP separates form field ownership. A field is either developer-owned in forms.<name>.fields or client-owned in clientFields. The two owners must not use the same field name. Developers keep control of core field keys, types, required validation, and server allowlists; authorized admins can change the displayed labels of those core fields. Client-owned fields are optional questions whose full field definitions can be managed in WordPress.

Let editors manage selected fields

Add clientFields to a native form contract when site editors should be able to add, remove, reorder, relabel, or change the required state of selected fields in WordPress:

cms/forms/contact.ts
1import { defineWpForm } from "@forgewp/react/config"
2
3export const form = defineWpForm({
4  mailTo: "admin",
5  subject: "New contact submission",
6  fields: {
7    name: { type: "text", label: "Name", required: true },
8    email: { type: "email", label: "Email", required: true },
9  },
10  // ADD THIS SECTION when editors should manage extra fields in WordPress.
11  clientFields: {
12    enabled: true,
13    seed: [
14      {
15        name: "subject",
16        label: "Subject",
17        type: "select",
18        required: true,
19        options: ["Support", "Partnership", "Other"],
20      },
21      {
22        name: "message",
23        label: "Message",
24        type: "textarea",
25        required: true,
26      },
27    ],
28  },
29  storeSubmissions: true,
30})

Keep the rest of your existing native form contract, including options such as subject and storeSubmissions. The clientFields section is the new addition in this example. Its seed initializes the editor-managed field list; later changes are stored in WordPress and are not overwritten by another theme export. The generated Forms admin menu has a separate child page for each opted-in form, so editors can manage its fields and messages without scrolling through every form at once.

Render the client-owned fields through a render prop so the developer still controls the visible markup:

Inside the form component
1import { WpFormFields } from "@forgewp/react"
2
3export function ClientOwnedFields() {
4  return (
5    <WpFormFields
6      form="contact"
7      render={(field) => (
8        <label key={field.name}>
9          {field.label}
10          {field.type === "textarea" ? (
11            <textarea
12              name={field.name}
13              required={field.required}
14              placeholder={field.placeholder}
15            />
16          ) : field.type === "select" ? (
17            <select name={field.name} required={field.required}>
18              <option value="">Choose one</option>
19              {field.options?.map((option) => (
20                <option key={option} value={option}>
21                  {option}
22                </option>
23              ))}
24            </select>
25          ) : (
26            <input
27              name={field.name}
28              type={field.type}
29              required={field.required}
30              placeholder={field.placeholder}
31            />
32          )}
33        </label>
34      )}
35    />
36  )
37}

WpFormFields does not add a wrapper or styling. It renders fields in the stored order and adds a visually hidden honeypot input. The form still submits all fields through the same submitWpForm("contact", new FormData(...)) call.

Preview client fields in Vite

With forgewpVirtualPlugin() enabled in your Vite configuration, development automatically supplies the resolved client-field seeds to WpFormFields. Forms discovered in cms/forms/ are merged with inline wp.config.ts forms using the same configuration loader as export; an inline form wins when the same key exists in both places. You do not need to pass the same seed into every component.

A flat seed array applies to every language. For a per-locale seed map, preview selects the configured locale from the first URL segment, such as /pt-BR/contact, and otherwise uses the configured default locale. A configured locale with no seed renders an empty field list rather than borrowing another language's fields. Client-side route changes use the new locale on the next render.

Edits to wp.config.ts or files under cms/forms/, including added or deleted form files, refresh the development runtime with a full page reload. Unsaved form inputs reset during that reload. This preview does not load saved WordPress admin overrides.

Only public field descriptors are copied into this preview payload: names, labels, types, required flags, select options, and placeholders. Developer-owned descriptors are available for opt-in local validation; WpFormFields renders only the client-owned fields. Mail recipients, mail templates, and other server settings are not copied by this injection. Production builds do not receive the new mock-form payload; WordPress supplies its own saved field data.

The optional fallback prop remains useful for isolated component previews and initial rendering before runtime data is available. WordPress hydration data takes priority over local mock data, which takes priority over fallback. An explicit empty field array is authoritative; it does not activate the fallback.

Local submission remains a simulation. Use validateLocal: true with the native form submission helpers to check basic contract rules. It does not test PHP validation, storage, rate limiting, or mail transport. Verify those on WordPress separately.

Read admin-customized labels with useWpFormFieldLabels

In wp-admin, site editors can customize the displayed labels of developer-owned fields without altering your backend validation rules or machine keys.

To display these customized labels dynamically in your React component, use useWpFormFieldLabels():

src/components/forms/ContactForm.tsx
1import { useWpFormFieldLabels, useWpForm } from "@forgewp/react"
2
3export function ContactForm() {
4  const { handleSubmit, isSubmitting, errors } = useWpForm("contact")
5
6  // Reads admin-customized labels from the hydration payload
7  const labels = useWpFormFieldLabels("contact")
8
9  return (
10    <form onSubmit={handleSubmit}>
11      <label>
12        {/* Falls back to the hardcoded developer default if not modified */}
13        {labels.name || "Your Name"}
14        <input name="name" required aria-invalid={!!errors.name} />
15      </label>
16      {errors.name && <p role="alert">{errors.name}</p>}
17
18      <label>
19        {labels.email || "Email Address"}
20        <input
21          name="email"
22          type="email"
23          required
24          aria-invalid={!!errors.email}
25        />
26      </label>
27      {errors.email && <p role="alert">{errors.email}</p>}
28
29      <button type="submit" disabled={isSubmitting}>
30        {isSubmitting ? "Sending..." : "Submit"}
31      </button>
32    </form>
33  )
34}

In local Vite development, useWpFormFieldLabels returns an empty object, safely falling back to your developer defaults. In production WordPress, it resolves directly from the localized hydration payload.

Multilingual forms & per-locale field seeding

Client-owned field labels and options are runtime WordPress content. For multilingual websites with i18n.locales configured in wp.config.ts, you can provide a locale-keyed seed map so each language seeds independently:

cms/forms/contact.ts (Multilingual Seed)
1export const form = defineWpForm({
2  mailTo: "admin",
3  fields: {
4    email: { type: "email", label: "Email", required: true },
5  },
6  clientFields: {
7    enabled: true,
8    // Per-locale seed map:
9    seed: {
10      en: [
11        {
12          name: "department",
13          label: "Department",
14          type: "select",
15          required: true,
16          options: ["Technical Support", "Billing & Sales", "General"],
17        },
18      ],
19      de: [
20        {
21          name: "department",
22          label: "Abteilung",
23          type: "select",
24          required: true,
25          options: [
26            "Technischer Support",
27            "Buchhaltung & Vertrieb",
28            "Allgemein",
29          ],
30        },
31      ],
32    },
33  },
34})

When a per-locale seed map is defined:

  1. Admin Language Tabs: The generated Forms admin screen displays dedicated language tabs, allowing clients to translate field labels and options for each language without clobbering other locales.
  2. Language-Aware Validation: submitWpForm() automatically transmits the visitor's current language code, ensuring that submissions validate against the exact field set and options displayed to that visitor.

A native WordPress search form submits a standard GET request using the query parameter s:

src/components/SearchBar.tsx
1export function SearchBar() {
2  return (
3    <form action="/" method="GET" role="search">
4      <label htmlFor="site-search">Search posts</label>
5      <input
6        id="site-search"
7        name="s"
8        type="search"
9        placeholder="Search publications..."
10      />
11      <button type="submit">Search</button>
12    </form>
13  )
14}

On submission, WordPress intercepts the ?s=keyword URL query. In standard WordPress theme hierarchy, if no specialized search template is present, search results fall back cleanly to index.php, where the standard post loop automatically renders the matching search results.

This approach works with 0 KB of client-side JavaScript and is the ideal default for standard content sites.

Flowchart
Rendering diagram...

Read the current query in an interactive UI

When building an interactive search experience, use useWpSearch() for the raw query string or useWpSearchParams() for parsed URL parameters. These hooks update automatically when browser history changes:

src/components/SearchStatus.tsx
1import { useWpSearchParams } from "@forgewp/react"
2
3export function SearchStatus() {
4  const params = useWpSearchParams()
5  const term = params.get("s") || ""
6  return (
7    <p>{term ? `Showing results for: "${term}"` : "Browse all publications"}</p>
8  )
9}

Query posts dynamically with useWpQuery

For live search experiences (such as as-you-type search bars, filter sidebars, or "Load More" pagination), use useWpQuery(). The s argument maps directly to full-text search:

src/components/InteractiveSearchResults.tsx
1import { useWpQuery } from "@forgewp/react"
2
3interface SearchResultsProps {
4  term: string
5}
6
7export function InteractiveSearchResults({ term }: SearchResultsProps) {
8  const { posts, loading, hasMore, loadMore } = useWpQuery({
9    postType: "post",
10    s: term,
11    postsPerPage: 6,
12  })
13
14  return (
15    <section className="space-y-6">
16      {loading && (
17        <p className="text-sm text-muted-foreground">Searching archive...</p>
18      )}
19
20      {!loading && posts.length === 0 && (
21        <p className="text-sm text-muted-foreground">
22          No publications matched your query.
23        </p>
24      )}
25
26      <div className="grid gap-6 md:grid-cols-2">
27        {posts.map((post) => (
28          <article
29            key={post.id}
30            className="rounded-xl border border-border p-6"
31          >
32            <h2 className="text-xl font-bold">{post.title}</h2>
33            <p className="mt-2 text-sm text-muted-foreground">{post.excerpt}</p>
34          </article>
35        ))}
36      </div>
37
38      {hasMore && (
39        <button
40          onClick={loadMore}
41          className="rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
42        >
43          Load more results
44        </button>
45      )}
46    </section>
47  )
48}

useWpQuery() reads mock data locally and queries the WordPress REST API bridge in production. Because it manages client state and asynchronous data fetching, wrap it inside an interactive island or use it within client components.

For static lists that do not need client-side filtering or dynamic pagination, prefer <WpQueryLoop>; it keeps the query in the server-rendered theme output. See Loops, Custom Post Types & Queries.

Which search pattern should I choose?

Use a normal GET form with name="s" for accessible, zero-JavaScript, URL-driven searches that route through WordPress's standard template hierarchy. Reach for useWpQuery() when you need live client-side filtering, debounced search modals, or infinite scrolling.