npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vulcantech/forms

v0.3.0

Published

Dynamic form renderer with TipTap-style builder, Yup validation, sensitive field encryption, and Tailwind styling

Downloads

47

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 undefined on a consumer site. The CMS strips projectId, integrations, notificationEmailTemplate, and applicationStatusEmails from every public response (stripSensitiveFields). Do not branch on them client-side, and never read definition.projectId — resolve the project from VULCANTECH_PROJECT_ID in your server-side proxy instead.
  • status is typed string, not FormStatus. Same for the *JSON mirror types. Compare against the literals (status === "published") rather than assuming a narrowed union. The CMS GET /api/forms already filters to published forms unless you pass includeDrafts=1, so you rarely need to check it.
  • sections[].order and fields[].layout.order drive render order, not array position — DynamicForm sorts by order before rendering. Field width comes from layout.width ("full" / "half" / "third") on a 6-column grid.
  • style is colors-optional by design. DEFAULT_FORM_STYLE supplies only the structural choices (preset, borderRadius, spacing, buttonVariant, buttonPlacement, sectionDividers, inputVariant). Every color is optional and resolves through CSS var() fallbacks, so an unstyled form inherits the host page rather than forcing its own palette.
  • FormDefinitionJSON vs FormDefinition. The strict type keeps string-literal unions; JSON parsed off a fetch widens them to string and won't assign. Type the raw value as FormDefinitionJSON, then call parseFormDefinition(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

  • url binding → 400 {"error":"Invalid url"}. The CMS compares the submitted url against the project's stored url by 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's url at your dev origin) rather than assuming the form is broken.
  • A top-level email is required on every submission type. After the credential, URL, rate-limit and honeypot gates, the CMS runs a Yup schema requiring a valid email and returns 400 {"error":"Validation failed","details":[…]}. Because builder-generated field ids look like field-1712…, a form satisfies this only if its email field's id is literally email, or the proxy adds a canonical email key alongside the raw field ids. A second pass then re-enforces the definition's own required rules (skipping showWhen-hidden fields), reporting "<Field label> is required" in the same details array.

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, calls onSuccess() with no argument, and makes no network request at all. Treat a missing argument in onSuccess as "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 answers 200 {"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, answering 200 {"success":true,"duplicate":true} with the original referenceNumber. 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 from transportKeyEndpoint, defaulting to /api/forms/transport-key on 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 isolated sensitive_data collection, and the submission keeps only { sensitiveDataId, last4 }. An envelope on a field the definition doesn't declare sensitive is rejected with 400 {"error":"Failed to process sensitive fields"} — the usual cause is a wrong or missing formId.

  • file — the value submitted is a URL string (or an array of them when multiple), 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. Give DynamicForm an upload path, exactly as you give it an apiRoute:

    <DynamicForm definition={def} apiRoute="/api/contact" fileUploadRoute="/api/form-upload" />

    fileUploadRoute is a route in your own app. It receives POST multipart/form-data with file (the File) and formId, and must answer 200 {"url": "https://…"} (optionally name, size); on failure, answer non-2xx with {"error": "…"} and that message renders under the field. The returned url must be durable — a blob:/data: URL is rejected. For full control (a client-side SDK upload, a signed URL, progress of your own), pass onFileUpload(file, config) instead; it wins over fileUploadRoute.

    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 under forms/{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 } where url is 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 error string 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.ts is the reference copy. The API key needs the contact:write scope (an attachment is part of a submission, so there is no new scope to provision), and — exactly as with /api/contact — the url must match the project's stored url, 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.createObjectURL and submitted a blob: URL, which looked like a successful upload and arrived as an unopenable attachment on the lead. DynamicForm also aborts any submission still carrying a blob:/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.

    FileField is also exported for use outside DynamicForm, taking the same handler as an onUpload prop.

    fileConfig.resizeImages (default true) downscales images in the browser before they reach your handler, to fileConfig.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. maxSizeBytes is checked against the resized file, so a photo that only fits once downscaled is accepted. Set resizeImages: false if 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

  • FormDefinition is 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 into newsletter_subscribers.
  • submitConfig.redirectUrl takes precedence over successBehavior on 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 through getSafeRedirectUrl, which accepts absolute http(s) URLs and site-relative paths only.
  • The package also ships buildYupSchema / buildInitialValues for custom render paths, but consumer sites use the server-fetcher pattern above for ISR caching.