# Hidden Fields

URL: https://docs.forms.saastro.io/docs/hidden-fields
> Capture metadata, tracking data, and computed values with invisible fields that resolve dynamically at runtime.

# Hidden Fields

Hidden fields capture contextual data without any visible UI. They use **resolvers** — declarative configs that compute a value when the form mounts. There are **19 built-in resolvers**, all JSON-serializable.

Unlike HTML's `<input type="hidden">`, a hidden field renders nothing at all — the value lives only in form state and is included in the submitted data.

---

## When to Use Hidden Fields

| Use Case                       | Example Resolver                          |
| ------------------------------ | ----------------------------------------- |
| Track form submission time     | `{ $resolver: 'timestamp' }`              |
| Capture all UTM parameters     | `{ $resolver: 'utm' }`                    |
| Capture an ad click id         | `{ $resolver: 'clickId' }`                |
| Record the referring page      | `{ $resolver: 'referrer' }`               |
| First-touch landing page       | `{ $resolver: 'landingPage' }`            |
| Visitor's timezone / device    | `{ $resolver: 'timezone' }` / `deviceType`|
| Read a cookie or storage value | `{ $resolver: 'cookie', name: '_ga' }`    |
| Unique id per form load        | `{ $resolver: 'submissionId' }`           |

---

## Quick Start

```tsx

const config = FormBuilder.create('contact')
  .addField('email', (f) => f.type('email').label('Email').required())
  // Invisible — resolved at form mount
  .addField('submitted_at', (f) => f.type('hidden').resolver({ $resolver: 'timestamp' }))
  .addField('utm_source', (f) =>
    f.type('hidden').resolver({
      $resolver: 'urlParam',
      param: 'utm_source',
      fallback: 'direct',
    }),
  )
  .addStep('main', ['email', 'submitted_at', 'utm_source'])
  .build();
```

On submit, the data includes:

```json
{
  "email": "user@example.com",
  "submitted_at": "2026-02-25T12:00:00.000Z",
  "utm_source": "google"
}
```

---

## Built-in Resolvers

### `timestamp`

Returns the current date/time as an ISO 8601 string.

```tsx
f.type('hidden').resolver({ $resolver: 'timestamp' });
// → "2026-02-25T12:34:56.789Z"
```

### `hostname`

Returns `window.location.hostname`.

```tsx
f.type('hidden').resolver({ $resolver: 'hostname' });
// → "example.com"
```

### `pageUrl`

Returns the full page URL (`window.location.href`).

```tsx
f.type('hidden').resolver({ $resolver: 'pageUrl' });
// → "https://example.com/contact?ref=nav"
```

### `referrer`

Returns `document.referrer` — the URL of the page that linked here.

```tsx
f.type('hidden').resolver({ $resolver: 'referrer' });
// → "https://www.google.com/search?q=react+form+library"
```

### `userAgent`

Returns the browser's user agent string.

```tsx
f.type('hidden').resolver({ $resolver: 'userAgent' });
// → "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ..."
```

### `urlParam`

Reads a query parameter from the current URL.

```tsx
f.type('hidden').resolver({
  $resolver: 'urlParam',
  param: 'utm_source', // required — param name
  fallback: 'direct', // optional — default if param not found
});
// URL: ?utm_source=google → "google"
// URL: (no param)         → "direct"
```

### `ip`

Fetches the visitor's IP address from an external API. This is the only **async** resolver.

```tsx
f.type('hidden').resolver({
  $resolver: 'ip',
  endpoint: 'https://api.ipify.org?format=json', // optional — this is the default
  fallback: 'unknown', // optional — used if the fetch fails
});
// → "203.0.113.42"
```

The endpoint must return JSON with an `ip` property (the default, [ipify](https://www.ipify.org/), does). The request is capped at **5 seconds** — on timeout or any fetch error the resolver returns `fallback` (or `''`), so a down IP service never blocks the form.

### Marketing attribution

All synchronous and client-side.

```tsx
// All `utm_*` query params, as an object: { utm_source, utm_medium, ... }
f.type('hidden').resolver({ $resolver: 'utm' });

// First present ad-click id (gclid, fbclid, msclkid, ttclid, li_fat_id, twclid, dclid).
// `params` overrides the list; `fallback` if none present.
f.type('hidden').resolver({ $resolver: 'clickId', fallback: '' });

// First-touch: the entry URL / referrer of the session, persisted to
// sessionStorage on the first run so later pages keep the original.
f.type('hidden').resolver({ $resolver: 'landingPage' });
f.type('hidden').resolver({ $resolver: 'firstReferrer' });
```

### Browser & device context

```tsx
f.type('hidden').resolver({ $resolver: 'language' });          // navigator.language → "es-ES"
f.type('hidden').resolver({ $resolver: 'timezone' });          // IANA → "Europe/Madrid"
f.type('hidden').resolver({ $resolver: 'screenResolution' });  // "1920x1080"
f.type('hidden').resolver({ $resolver: 'deviceType' });        // "mobile" | "tablet" | "desktop"
f.type('hidden').resolver({ $resolver: 'pageTitle' });         // document.title
```

### Storage & generated

```tsx
// A named cookie (decoded).
f.type('hidden').resolver({ $resolver: 'cookie', name: '_ga', fallback: '' });

// localStorage / sessionStorage by key.
f.type('hidden').resolver({ $resolver: 'storage', key: 'visitor_id', area: 'local', fallback: '' });

// A fresh UUID per form load (idempotency / dedup).
f.type('hidden').resolver({ $resolver: 'submissionId' });
```

---

## Server-side capture (IP & geo)

Client resolvers run in the browser, so the IP/geo they report can be spoofed
and depend on external APIs. When a form is submitted through the hosted Saastro
backend (`submit.saastro.io`, e.g. via [HubForm](/docs/hub-form)), the **server**
adds trustworthy context to every submission from the Cloudflare edge — no field
config needed:

| Field            | Value                                         |
| ---------------- | --------------------------------------------- |
| `client_ip`      | Real connecting IP (`CF-Connecting-IP`)       |
| `geo_country`    | Country code, e.g. `ES`                       |
| `geo_city`       | City                                          |
| `geo_region`     | Region / state                                |
| `geo_postal_code`| Postal code                                   |
| `geo_timezone`   | IANA timezone                                 |
| `geo_latitude` / `geo_longitude` | Approximate coordinates        |

These are **server-authoritative** — a client posting a `geo_country` field can't
override them. They land in the stored submission and flow to integrations
(Google Sheets adds a column per field). Capture is **on by default**; turn it off
per form with the **Data Capture** toggle in the visual builder's Form Settings
(privacy / GDPR). Prefer this over the client `ip` resolver when you control the
backend.

---

## How Resolvers Execute

The `useHiddenFieldResolvers` hook runs inside the `Form` component:

1. On mount, it filters all fields with `type: 'hidden'`
2. Runs all resolvers in parallel via `Promise.all`
3. Once all resolvers finish, sets each value with `react-hook-form`'s `setValue(name, value, { shouldDirty: false })`
4. A cleanup function prevents stale writes if the component unmounts

Most resolvers are synchronous (timestamp, hostname, etc.). The `ip` resolver uses `fetch` and is async. All resolvers execute concurrently regardless.

---

## Resolvers vs Field Mapping Inject

Both resolvers and field mapping `inject` can add computed values. Use the right tool:

| Feature                     | Hidden Field Resolver    | Field Mapping Inject                |
| --------------------------- | ------------------------ | ----------------------------------- |
| When it runs                | Form mount (once)        | Before submit action                |
| Visible in form state       | Yes (via `setValue`)     | No (transform only)                 |
| Configurable per-field      | Yes                      | Per submit action                   |
| Supports `custom` resolver  | No (serializable only)   | Yes (`inject` accepts any resolver) |

**Use resolvers** when the value should be part of the form state (available to conditional logic and anything else that reads form values).

**Use inject** when you only need the value at submit time and it doesn't need to be in form state.

---

## JSON Configuration

Hidden fields are fully serializable — no functions required:

```json
{
  "fields": {
    "submitted_at": {
      "type": "hidden",
      "resolver": { "$resolver": "timestamp" }
    },
    "utm_source": {
      "type": "hidden",
      "resolver": {
        "$resolver": "urlParam",
        "param": "utm_source",
        "fallback": "direct"
      }
    },
    "visitor_ip": {
      "type": "hidden",
      "resolver": {
        "$resolver": "ip",
        "fallback": "unknown"
      }
    }
  }
}
```

This makes hidden fields compatible with any visual builder or CMS that stores form config as JSON.

---

## The `custom` Resolver (Not for Hidden Fields)

The full `FieldResolver` union has an eighth member, `{ $resolver: 'custom', fn: () => unknown }`, which runs an arbitrary function. **Hidden fields exclude it by design**: `HiddenFieldProps.resolver` and `FieldBuilder`'s `.resolver()` method are typed `SerializableFieldResolver` (the union minus `custom`), so there is no type-supported way to attach a custom resolver to a hidden field — hidden field configs stay fully JSON-serializable.

Where `custom` _is_ supported at the config level is a submit action's `fieldMapping.inject`, which accepts any resolver:

```tsx
const config = FormBuilder.create('contact')
  .addField('email', (f) => f.type('email').label('Email').required())
  .addStep('main', ['email'])
  .submitAction(
    'crm',
    {
      type: 'http',
      name: 'CRM',
      endpoint: { url: 'https://api.example.com/leads', method: 'POST' },
    },
    'onSubmit',
    {
      fieldMapping: {
        inject: {
          request_id: { $resolver: 'custom', fn: () => crypto.randomUUID() },
        },
      },
    },
  )
  .build();
```

(The exported `resolveValue` / `applyFieldMapping` utilities also execute `custom` resolvers if you call them directly. A `custom` resolver that throws is caught: it logs a warning and resolves to `''` instead of failing the submit.)

If you need a custom **value in form state**, use the field-level `computed` config (reactive, derived from other fields — see [useComputedFields](/docs/use-computed-fields)) or set the value imperatively via the `Form` ref's `setValue`.

---

## TypeScript Types

All three types are exported from the package:

```tsx
```

Their shapes, for reference:

```tsx
// Full resolver union (includes 'custom')
type FieldResolver =
  | { $resolver: 'timestamp' }
  | { $resolver: 'hostname' }
  | { $resolver: 'pageUrl' }
  | { $resolver: 'referrer' }
  | { $resolver: 'userAgent' }
  | { $resolver: 'urlParam'; param: string; fallback?: string }
  | { $resolver: 'ip'; endpoint?: string; fallback?: string }
  | { $resolver: 'custom'; fn: () => unknown };

// Serializable subset (JSON-safe — what hidden fields require)
type SerializableFieldResolver = Exclude<FieldResolver, { $resolver: 'custom' }>;

// The hidden field config
interface HiddenFieldProps extends BaseFieldProps {
  type: 'hidden';
  resolver: SerializableFieldResolver;
}
```

---

## API Reference

### `resolveValue(resolver)`

Async function that executes any `FieldResolver` (including `custom`) and returns its value.

```tsx

const value = await resolveValue({ $resolver: 'timestamp' });
// → "2026-02-25T12:34:56.789Z"
```

### `resolveValueSync(resolver)`

Synchronous variant — useful for dry-running a config without side effects, e.g. to preview what a form will capture. Returns instant values for synchronous resolvers and placeholder strings for async ones: `ip` returns its `fallback` if set, otherwise `"[fetching IP...]"`; `custom` returns `"[custom resolver]"`.

```tsx

const value = resolveValueSync({ $resolver: 'timestamp' });
// → "2026-02-25T12:34:56.789Z"

const ipValue = resolveValueSync({ $resolver: 'ip' });
// → "[fetching IP...]"
```

### `useHiddenFieldResolvers(methods, fields)`

React hook that resolves all hidden field values on mount. See the [useHiddenFieldResolvers](/docs/use-hidden-field-resolvers) reference for details.

```tsx

// Used internally by the Form component — you don't need to call this directly
useHiddenFieldResolvers(formMethods, formConfig.fields);
```

### `BUILTIN_RESOLVERS`

Constant array with `{ id, label, description }` metadata for all 7 built-in resolvers — useful for building resolver pickers in your own tooling.

```tsx

// [
//   { id: 'timestamp', label: 'Timestamp', description: 'Current date/time in ISO format' },
//   { id: 'hostname', label: 'Hostname', description: 'Current page hostname' },
//   ...
// ]
```
