@vulcantech/forms
v0.3.0
Published
Dynamic form renderer with TipTap-style builder, Yup validation, sensitive field encryption, and Tailwind styling
Downloads
47
Maintainers
Readme
@vulcantech/forms
Dynamic form renderer for VulcanTech apps. Renders a form from a JSON FormDefinition (authored in the CMS form builder) using Formik + Yup, with sensitive-field encryption and Tailwind styling.
Exports: DynamicForm, DynamicFormSection, DynamicFormField, buildYupSchema, buildInitialValues, parseFormDefinition, getSafeRedirectUrl, renderEmailTemplate, encryptSensitiveValue, the useFormDefinition / useUndoRedo / useDragReorder builder hooks, and the FormDefinition / FormDefinitionJSON / FormFieldDefinition / FormStyle / FormSubmissionType types.
Field types: text, email, phone, url, address, textarea, select, checkbox, radio, number, date, datetime, hidden, sensitive, file.
Form shape (FormDefinition)
A FormDefinition is the CMS-authored schema for one form. Consumer sites never build it by hand — they fetch it from the CMS GET /api/forms and hand it to <DynamicForm>.
interface FormDefinition {
id: string;
projectId?: string; // SERVER-ONLY — stripped from every client response (see below)
name: string;
slug: string; // unique per project; the CMS 409s on a collision
description?: string;
version: number; // bumped on each CMS edit; emitted as data-form-version
status: string; // "draft" | "published" | "archived" — typed as string, NOT the union
submissionType?: FormSubmissionType; // absent = "contact"; decides the target collection
bookingConfig?: FormBookingConfig; // only read when submissionType === "booking"
sections: FormSection[]; // render order is `order`, NOT array order
submitConfig: FormSubmitConfig; // button text, success/error copy, redirect, spam settings
style?: FormStyle; // absent = DEFAULT_FORM_STYLE (structure only, no colors)
defaultTextColor?: string; // hex; per-section `textColor` overrides it
metadata?: { trackAdReferral?: boolean; customFields?: Record<string, unknown> };
integrations?: FormIntegrations; // SERVER-ONLY — stripped
notificationEmailTemplate?: NotificationEmailTemplate; // SERVER-ONLY — stripped
applicationStatusEmails?: ApplicationStatusEmailTemplates; // SERVER-ONLY — stripped
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
createdBy: string; // CMS user uid
}Field notes:
- Four fields are server-only and will always be
undefinedon a consumer site. The CMS stripsprojectId,integrations,notificationEmailTemplate, andapplicationStatusEmailsfrom every public response (stripSensitiveFields). Do not branch on them client-side, and never readdefinition.projectId— resolve the project fromVULCANTECH_PROJECT_IDin your server-side proxy instead. statusis typedstring, notFormStatus. Same for the*JSONmirror types. Compare against the literals (status === "published") rather than assuming a narrowed union. The CMSGET /api/formsalready filters to published forms unless you passincludeDrafts=1, so you rarely need to check it.sections[].orderandfields[].layout.orderdrive render order, not array position —DynamicFormsorts byorderbefore rendering. Field width comes fromlayout.width("full"/"half"/"third") on a 6-column grid.styleis colors-optional by design.DEFAULT_FORM_STYLEsupplies only the structural choices (preset,borderRadius,spacing,buttonVariant,buttonPlacement,sectionDividers,inputVariant). Every color is optional and resolves through CSSvar()fallbacks, so an unstyled form inherits the host page rather than forcing its own palette.FormDefinitionJSONvsFormDefinition. The strict type keeps string-literal unions; JSON parsed off afetchwidens them tostringand won't assign. Type the raw value asFormDefinitionJSON, then callparseFormDefinition(json)— an unchecked structural cast that trusts the CMS, not a validator.
Where a submission lands (submissionType)
submissionType is read from the form definition on the server, never from the request body. A submission whose formId doesn't resolve always routes to contact.
| submissionType | Firestore collection | Notes |
| --- | --- | --- |
| contact (default) | projects/{id}/contact-forms | Values stored flat at the doc top level. |
| application | projects/{id}/applications | Values nested under data; adds referenceNumber, status: "received", statusHistory. The route echoes referenceNumber back in the response. |
| newsletter | projects/{id}/newsletter_subscribers | Restricted to email + checkbox fields (NEWSLETTER_ALLOWED_FIELD_TYPES); checkbox ids become preferences booleans. A repeat email updates the existing record instead of creating one. |
| booking | projects/{id}/bookings | Values nested under data, plus a denormalized date. |
For booking, the CMS resolves the booking date in three steps: bookingConfig.dateFieldId → the first date/datetime field in render order → a top-level date key. Date-only convention: a date field submits "yyyy-MM-dd", which parses to UTC midnight, so a stored epoch that is an exact multiple of 86,400,000 is treated as date-only and formatted in UTC; a datetime field submits a full ISO string and is treated as a real instant.
Submitting (DynamicForm)
<DynamicForm definition={…} apiRoute="/api/contact" /> POSTs a flat JSON object to apiRoute — the form's own field ids as top-level keys, plus anything in extraData, plus formId: definition.id. There is no data or values wrapper. Before sending it strips the honeypot, fields hidden by an unmet showWhen, and the synthetic _sameAs_* toggles.
Your apiRoute is expected to be a server-side proxy in your own app that adds the credentials and forwards to the CMS POST /api/contact. The renderer never sends projectId, apiKey, or url — the proxy adds those. Forward the browser's body through whole; hand-picking fields into a new object is what silently destroys leads once the CMS re-validates the definition's required fields.
Other DynamicForm props: onSuccess(data?) (receives the parsed response body — this is how you read referenceNumber), onError(error), extraData, className, onFileUpload / fileUploadRoute (required for file fields — see Field types needing extra wiring), onFormView / onFormSubmit (analytics; they fall back to window.__vtTrack so a site running @vulcantech/analytics tracks conversions with no wiring), transportKeyEndpoint, and the preview* props used only by the CMS builder.
Two CMS rejections that will bite you
urlbinding →400 {"error":"Invalid url"}. The CMS compares the submittedurlagainst the project's storedurlby exact string equality. Proxies therefore hardcode the production origin, which means submissions from a localhost dev server are rejected. Test against the CMS directly (or temporarily point the project'surlat your dev origin) rather than assuming the form is broken.- A top-level
emailis required on every submission type. After the credential, URL, rate-limit and honeypot gates, the CMS runs a Yup schema requiring a validemailand returns400 {"error":"Validation failed","details":[…]}. Because builder-generated field ids look likefield-1712…, a form satisfies this only if its email field's id is literallyemail, or the proxy adds a canonicalemailkey alongside the raw field ids. A second pass then re-enforces the definition's ownrequiredrules (skippingshowWhen-hidden fields), reporting"<Field label> is required"in the samedetailsarray.
Failure mode
DynamicForm never throws at the call site — it catches everything internally, sets its own error banner, and calls onError(error). There is no promise to await and no exception to catch around the component.
Three outcomes look like success but aren't a stored lead, and each is deliberate:
- Spam (honeypot filled, or submitted faster than
minSubmitTime, default 3s). The renderer shows the success banner, callsonSuccess()with no argument, and makes no network request at all. Treat a missing argument inonSuccessas "no server response", not as an error. - Server-side honeypot. If a filled honeypot does reach the CMS it writes the row with
status: "spam"and still answers200 {"success":true}— indistinguishable from success by design. - Duplicate. The CMS fingerprints each submission (all values except
apiKey,projectId,url,formId,formType,submittedAt,_hp_website) and suppresses an identical resubmit within 10 minutes, answering200 {"success":true,"duplicate":true}with the originalreferenceNumber. No second document, no second notification email. Dedupe fails open — a Firestore outage lets the write through rather than dropping a lead. Forms with sensitive fields are never fingerprinted (fresh ciphertext each submit).
Other CMS statuses reach onError with the server's error string as the message: 413 (payload over 256KB, any string over 20,000 chars, or over 100 top-level keys), 429 (60 submissions/min per IP, 200/hour per project), 401/403 (bad or out-of-scope API key), 400 for the two rejections above.
Field types needing extra wiring
sensitive— encrypted in the browser (RSA-OAEP + AES-GCM) before submission. The renderer fetches the wrapping key fromtransportKeyEndpoint, defaulting to/api/forms/transport-keyon the same origin. That route exists in the CMS, not in consumer apps, so a site with a sensitive field must either proxy it or pass an absolute CMS URL. Values arrive at the CMS as envelopes, get re-encrypted with KMS into an isolatedsensitive_datacollection, and the submission keeps only{ sensitiveDataId, last4 }. An envelope on a field the definition doesn't declare sensitive is rejected with400 {"error":"Failed to process sensitive fields"}— the usual cause is a wrong or missingformId.file— the value submitted is a URL string (or an array of them whenmultiple), never file bytes; the CMS stores it as an ordinary string. Uploads are not automatic: this package ships no default uploader, because a public form submitter holds no CMS credentials. GiveDynamicForman upload path, exactly as you give it anapiRoute:<DynamicForm definition={def} apiRoute="/api/contact" fileUploadRoute="/api/form-upload" />fileUploadRouteis a route in your own app. It receivesPOSTmultipart/form-datawithfile(the File) andformId, and must answer200 {"url": "https://…"}(optionallyname,size); on failure, answer non-2xx with{"error": "…"}and that message renders under the field. The returnedurlmust be durable — ablob:/data:URL is rejected. For full control (a client-side SDK upload, a signed URL, progress of your own), passonFileUpload(file, config)instead; it wins overfileUploadRoute.The standard implementation of that route is a thin proxy to the CMS — copy it rather than inventing storage of your own. The CMS exposes
POST /api/forms/upload, the credentialed counterpart to/api/contact: same apiKey +url-binding trust model, per-IP and per-project rate limits, a 10 MB hard cap, and a content-type allowlist verified against the file's magic bytes (no SVG, no HTML — both execute script when opened from the storage origin). Files land underforms/{projectId}/uploads/…, never the media library, so lead attachments stay out of the staff media picker, and deleting a submission deletes its attachments with it. The response is{ url, name, size }whereurlis a non-expiring Firebase download-token URL — chosen over a signed URL because the value is persisted verbatim on the lead and staff may open it months later.Your route's only job is to add the credentials the browser must never hold:
// app/api/form-upload/route.ts const outgoing = new FormData(); outgoing.append("file", file, file.name); outgoing.append("formId", formId); outgoing.append("projectId", process.env.VULCANTECH_PROJECT_ID!); outgoing.append("apiKey", process.env.VULCANTECH_API_KEY!); outgoing.append("url", SITE_URL); // must equal the project's stored `url` const res = await fetch(`${CMS_URL}/api/forms/upload`, { method: "POST", body: outgoing });Relay the CMS's
errorstring verbatim on a non-2xx — it renders under the field, and a message like "That file's contents don't match its type" is worth the user seeing.apps/castlerents/src/app/api/form-upload/route.tsis the reference copy. The API key needs thecontact:writescope (an attachment is part of a submission, so there is no new scope to provision), and — exactly as with/api/contact— theurlmust match the project's storedurl, which is why localhost dev uploads always 400.With neither prop set, the picker renders disabled and says so, and nothing is submitted. That is deliberate: the field previously fell back to
URL.createObjectURLand submitted ablob:URL, which looked like a successful upload and arrived as an unopenable attachment on the lead.DynamicFormalso aborts any submission still carrying ablob:/data:value rather than storing a dead link. The CMS form builder's live preview is the one exception — it keeps the local stand-in, since its submissions never leave the browser.FileFieldis also exported for use outsideDynamicForm, taking the same handler as anonUploadprop.fileConfig.resizeImages(default true) downscales images in the browser before they reach your handler, tofileConfig.maxImageWidth(default 1600). It only touches JPEG/PNG/WebP wider than the limit, keeps the original format, and hands the file over untouched if anything about the round-trip fails — GIF is skipped so an animation isn't flattened to its first frame, and HEIC/HEIF because only Safari decodes it.maxSizeBytesis checked against the resized file, so a photo that only fits once downscaled is accepted. SetresizeImages: falseif your handler wants the original bytes.
Integration
Wire forms into a consumer site (Next.js App Router):
1. Add the dependency:
npm install @vulcantech/forms(Inside the VulcanTech monorepo, use the workspace wiring in MONOREPO.md instead.)
2. Set the env — see Required env below.
3. Tailwind content (required) — add the package to Tailwind's sources, or the renderer's classes won't generate and forms render unstyled. Tailwind v4 (in the app's globals.css / tailwind.css); adjust the path to where the package resolves from:
@source "../node_modules/@vulcantech/forms/dist";(JS config: add the equivalent glob to content. Monorepo apps point at the package source instead — see MONOREPO.md.)
4. Server fetcher src/lib/cms/forms.ts — ISR-cached wrapper over the CMS /api/forms. Exposes getFormById(id) / getFormBySlug(slug); tags each fetch form:{projectId}:{formId} + forms:{projectId}; returns null on failure so the page still renders.
GET /api/forms takes its credentials as query params, not headers: projectId and apiKey (required, scope forms:read), then formId or slug to fetch one form (formId wins if both are sent), and optional includeDrafts=1 to bypass the published-only filter. With neither formId nor slug it returns { forms: [{ id, name, slug, status, updatedAt }] }. A single form comes back as the definition object itself with id set from the Firestore doc id. It answers 404 {"error":"Form not found"} for a missing, deleted, or unpublished form, and sends Cache-Control: private, no-store deliberately — caching is the consumer's job via Next fetch tags plus /api/revalidate.
5. Per-form loader src/lib/cms/load<Name>Form.ts — pin the form's Firestore id and wrap the fetcher with a fallback:
import { getFormById } from "./forms";
export const CONTACT_FORM_ID = "<firestore-doc-id-from-CMS>";
export async function loadContactForm() {
try { return (await getFormById(CONTACT_FORM_ID)) ?? undefined; }
catch { return undefined; }
}6. Render — load server-side, pass to a "use client" wrapper around <DynamicForm>:
// page.tsx (server component)
const definition = await loadContactForm();
return <ContactForm definition={definition} />;
// ContactForm.tsx ("use client")
import { DynamicForm, type FormDefinition } from "@vulcantech/forms";
export function ContactForm({ definition }: { definition?: FormDefinition }) {
return <DynamicForm definition={definition ?? FALLBACK_DEFINITION} apiRoute="/api/contact" />;
}7. ISR invalidation — ensure src/app/api/revalidate/route.ts exists, set VULCANTECH_REVALIDATE_SECRET, and register the site under Settings → Form Consumers (ISR Revalidation) in the CMS. The CMS purges form:{projectId}:{formId} (single) and forms:{projectId} (list) on edit. This is the same route and consumer entry the blog, banners, and SEO-files dispatchers use — one per site. Route contract and reference copy: the @vulcantech/cms README, ISR revalidation.
8. File-upload proxy (only if any form has a file field) — add src/app/api/form-upload/route.ts and pass fileUploadRoute="/api/form-upload" alongside apiRoute. The route re-posts the multipart body to the CMS /api/forms/upload with projectId / apiKey / url attached server-side, and relays the CMS's error string on failure. See Field types needing extra wiring for the full contract and apps/castlerents/src/app/api/form-upload/route.ts for the reference copy. Without it a file field renders a disabled picker saying uploads aren't configured.
Required env
VULCANTECH_PROJECT_ID, VULCANTECH_API_KEY (required) · VULCANTECH_REVALIDATE_SECRET (required for instant ISR purges; see step 7) · VULCANTECH_CMS_URL (optional) · VULCANTECH_FORMS_NO_CACHE (optional, 1/true to bypass ISR).
Per-app values
- Form definition ID(s) — Firestore doc id from the CMS admin (one per form), pinned in
load<Name>Form.ts(e.g.CONTACT_FORM_ID,SERVICE_REQUEST_FORM_ID).
Notes
FormDefinitionis the CMS-authored schema (sections, fields, submit config, style) — see Form shape above.style.buttonPlacement: "inline"renders the submit button beside the fields instead of full-width below them — suited to single-field forms (newsletter email + Join). Editable in the CMS form builder under Style → Submit Button → Button Placement.- Newsletter forms (
submissionType: "newsletter") should submit to an app route that proxies to the CMS/api/contact— the CMS routes them intonewsletter_subscribers. submitConfig.redirectUrltakes precedence oversuccessBehavioron genuine submissions (typically a thank-you page for ad conversion tracking). It fires only on a real success — the spam path deliberately stays on-page so a detected bot never triggers the conversion tag. Values are passed throughgetSafeRedirectUrl, which accepts absolute http(s) URLs and site-relative paths only.- The package also ships
buildYupSchema/buildInitialValuesfor custom render paths, but consumer sites use the server-fetcher pattern above for ISR caching.
