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 CaseExample 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

import { FormBuilder } from '@saastro/forms';

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:

{
  "email": "[email protected]",
  "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.

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

hostname

Returns window.location.hostname.

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

pageUrl

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

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

referrer

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

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

userAgent

Returns the browser’s user agent string.

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.

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.

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, 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.

// 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

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

// 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), the server adds trustworthy context to every submission from the Cloudflare edge — no field config needed:

FieldValue
client_ipReal connecting IP (CF-Connecting-IP)
geo_countryCountry code, e.g. ES
geo_cityCity
geo_regionRegion / state
geo_postal_codePostal code
geo_timezoneIANA timezone
geo_latitude / geo_longitudeApproximate 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:

FeatureHidden Field ResolverField Mapping Inject
When it runsForm mount (once)Before submit action
Visible in form stateYes (via setValue)No (transform only)
Configurable per-fieldYesPer submit action
Supports custom resolverNo (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:

{
  "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:

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) or set the value imperatively via the Form ref’s setValue.


TypeScript Types

All three types are exported from the package:

import type { HiddenFieldProps, FieldResolver, SerializableFieldResolver } from '@saastro/forms';

Their shapes, for reference:

// 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.

import { resolveValue } from '@saastro/forms';

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]".

import { resolveValueSync } from '@saastro/forms';

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 reference for details.

import { useHiddenFieldResolvers } from '@saastro/forms';

// 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.

import { BUILTIN_RESOLVERS } from '@saastro/forms';

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