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
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:
| 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:
- On mount, it filters all fields with
type: 'hidden' - Runs all resolvers in parallel via
Promise.all - Once all resolvers finish, sets each value with
react-hook-form’ssetValue(name, value, { shouldDirty: false }) - 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:
{
"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' },
// ...
// ]