Back to Blog
Engineering 12 min read

Why Headless Forms Are Replacing Embedded iFrames in 2026

A
Aayush KumarFounder, Forge · Published March 15, 2026

If you're still embedding Typeform or Jotform iframes in your React app in 2026, you're paying a hidden tax on every page load: bloated JavaScript bundles, measurable Core Web Vitals regressions, and a form that looks nothing like the rest of your product. A headless form builder for React solves all three problems at once — and with Forge, the migration takes about 15 minutes.

This guide covers what headless forms actually are, why they outperform iFrame-based solutions on every metric that matters, and exactly how to implement one in your Next.js or React app using the @forge/react SDK.

1. What “Headless” Actually Means for Forms

The word headless is overloaded in frontend circles, but its meaning for forms is precise: the backend infrastructure (submission storage, spam protection, validation, webhooks, notifications, and analytics) is fully decoupled from the presentation layer (the HTML, CSS, and JavaScript that renders the form UI).

In practice this means you own 100% of the UI. You use your own React components, your own design tokens, your own Tailwind classes — or no framework at all. Forge stores responses, enforces schema validation server-side, fires webhooks to your CRM or Slack, and delivers a typed API response. The browser never loads any third-party render library.

Contrast this with the iFrame model: the browser downloads an entirely separate HTML document, parses a second JavaScript runtime, and injects it into a sandboxed box on your page. The third-party controls fonts, colors, animations, and accessibility. You control nothing except the iframe dimensions.

2. The Real Performance Cost of iFrame Forms

Google's Core Web Vitals penalise pages that load slowly or shift layout unexpectedly. iFrame-based forms are one of the most common culprits behind both.

Here is what the browser actually has to do when it encounters a Typeform iframe:

  1. Parse and render your page's HTML, CSS, and JavaScript
  2. Create an isolated iframe browsing context with a separate document
  3. Fetch the third-party's HTML shell (a network round trip to their CDN)
  4. Download their JavaScript bundle — Typeform ships ~650 KB of JS, Jotform ~480 KB
  5. Execute their rendering pipeline, which can block your main thread for 200–400 ms
  6. Swap the placeholder height for the actual rendered form, triggering Cumulative Layout Shift

The table below compares a typical iFrame-based implementation against the equivalent headless Forge implementation on the same page:

MetriciFrame form (Typeform / Jotform)Headless (Forge + @forge/react)
Initial JS payload450–650 KB (third-party bundle)~8 KB (SDK tree-shaken)
LCP impact+180–350 ms0 ms (component renders server-side)
Cumulative Layout Shift+0.08–0.25 (iframe resize)0.000 (static dimensions)
Design controlNone — third-party stylesheetFull — your CSS, your tokens
Data ownershipVendor-held; export-limitedFull ownership; API access
Offline / SSR supportNo (requires live CDN)Yes (schema renders at build time)
Typical conversion liftBaseline+12–23% (no iframe boundary)

3. Building Your First Headless Form with @forge/react

The @forge/react SDK wraps React Hook Form and Zod under the hood, producing a fully type-safe form bound to your Forge backend endpoint. Install it alongside Zod:

npm install @forge/react zod

Then create your form schema and component. The useForgeForm hook accepts your Forge form ID, a Zod schema, and optional default values. It returns all React Hook Form primitives — so anything you can do with RHF, you can do here.

// components/LeadCaptureForm.tsx
"use client";

import { useForgeForm } from "@forge/react";
import { z } from "zod";

// 1. Define your schema — validated both client-side and server-side
const schema = z.object({
  firstName: z.string().min(1, "First name is required"),
  workEmail:  z.string().email("Enter a valid work email"),
  company:    z.string().min(2, "Company name is too short"),
  teamSize:   z.enum(["1-10", "11-50", "51-200", "200+"], {
    errorMap: () => ({ message: "Select your team size" }),
  }),
  message: z.string().min(10, "Tell us a bit more").max(600).optional(),
});

type LeadFormData = z.infer<typeof schema>;

export function LeadCaptureForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isSubmitSuccessful },
    watch,
  } = useForgeForm<LeadFormData>({
    formId: process.env.NEXT_PUBLIC_FORGE_FORM_ID!,
    schema,
    defaultValues: { teamSize: "11-50" },
  });

  if (isSubmitSuccessful) {
    return (
      <div className="p-8 bg-green-50 border border-green-200 rounded-2xl text-center">
        <p className="text-green-700 font-semibold text-lg">
          ✓ Request received! We&apos;ll be in touch within one business day.
        </p>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-5" noValidate>
      {/* Name */}
      <div>
        <label className="block text-sm font-semibold text-zinc-700 mb-1.5">
          First name
        </label>
        <input
          {...register("firstName")}
          placeholder="Ada"
          className={`w-full h-11 px-4 rounded-xl border text-sm transition-colors
            ${errors.firstName ? "border-red-400 bg-red-50" : "border-zinc-300 focus:border-blue-500"}`}
        />
        {errors.firstName && (
          <p className="text-red-500 text-xs mt-1">{errors.firstName.message}</p>
        )}
      </div>

      {/* Work email */}
      <div>
        <label className="block text-sm font-semibold text-zinc-700 mb-1.5">
          Work email
        </label>
        <input
          {...register("workEmail")}
          type="email"
          placeholder="ada@acme.com"
          className={`w-full h-11 px-4 rounded-xl border text-sm transition-colors
            ${errors.workEmail ? "border-red-400 bg-red-50" : "border-zinc-300 focus:border-blue-500"}`}
        />
        {errors.workEmail && (
          <p className="text-red-500 text-xs mt-1">{errors.workEmail.message}</p>
        )}
      </div>

      {/* Team size */}
      <div>
        <label className="block text-sm font-semibold text-zinc-700 mb-1.5">
          Team size
        </label>
        <select
          {...register("teamSize")}
          className="w-full h-11 px-4 rounded-xl border border-zinc-300 text-sm focus:border-blue-500"
        >
          {["1-10", "11-50", "51-200", "200+"].map((s) => (
            <option key={s} value={s}>{s} people</option>
          ))}
        </select>
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full h-12 bg-blue-600 hover:bg-blue-500 disabled:opacity-60 text-white
                   font-bold rounded-xl text-sm transition-colors shadow-lg shadow-blue-500/20"
      >
        {isSubmitting ? "Submitting…" : "Request early access →"}
      </button>
    </form>
  );
}

Notice what you did not have to do: write a fetch() call, manage loading state in useState, handle errors from a raw API response, or touch a submission database. Forge handles all of that. You own the UI.

4. Advanced Patterns: Multi-Step Forms, Conditional Logic & Hidden Fields

The useForgeForm hook exposes the full React Hook Form API, which means multi-step forms are just a step-index in React state. Use trigger(["field1", "field2"]) to validate only the current step's fields before advancing — then submit the complete payload on the final step.

// Multi-step validation pattern
const { trigger, handleSubmit } = useForgeForm({ formId, schema });
const [step, setStep] = useState(0);

const STEP_FIELDS = [
  ["firstName", "workEmail"],   // Step 1 fields
  ["company", "teamSize"],      // Step 2 fields
  ["message"],                  // Step 3 fields
] as const;

const handleNext = async () => {
  const valid = await trigger(STEP_FIELDS[step] as string[]);
  if (valid) setStep((s) => s + 1);
};

For hidden UTM tracking fields — which fewer than 30% of forms capture, costing marketing teams their attribution data — use the defaultValues option with client-side URL parsing:

// Capture UTM params automatically as hidden fields
const params = new URLSearchParams(
  typeof window !== "undefined" ? window.location.search : ""
);

const { register, handleSubmit } = useForgeForm({
  formId: process.env.NEXT_PUBLIC_FORGE_FORM_ID!,
  schema,
  defaultValues: {
    utm_source:   params.get("utm_source")   ?? "",
    utm_medium:   params.get("utm_medium")   ?? "",
    utm_campaign: params.get("utm_campaign") ?? "",
  },
});

// These ship as data on every submission — invisible to the user
<input type="hidden" {...register("utm_source")} />
<input type="hidden" {...register("utm_medium")} />
<input type="hidden" {...register("utm_campaign")} />

UTM data then appears in every response row in the Forge dashboard and in webhook payloads, so your team can finally attribute lead quality back to the campaign that drove it.

5. When iFrames Still Make Sense (and When They Don't)

To be honest: not every team should go headless on day one. Here is how to decide.

Stick with iFrame if…

  • ✗ You have a static CMS site with no React runtime
  • ✗ Your team has zero engineering bandwidth this sprint
  • ✗ The form is a one-off survey, not a product-critical flow
  • ✗ You need the vendor's specific question types (NPS, ranking, etc.)

Go headless if…

  • ✓ You have a React / Next.js codebase with a design system
  • ✓ Form performance affects your Core Web Vitals score
  • ✓ You need full data ownership for GDPR or SOC 2
  • ✓ Your form is core to conversion (pricing page, demo request, onboarding)
  • ✓ You want to iterate on form UX without touching a vendor dashboard

The Forge philosophy is “headless by default, visual builder when you need it.” The same Forge Form ID works with both the @forge/react SDK and the Forge no-code embed. You can start with the embed on your marketing site today and migrate critical conversion flows to the SDK later — all data lands in the same dashboard.

“We replaced our Typeform embed on the pricing page with a Forge React component. LCP dropped from 3.8 s to 2.1 s and demo request conversion went up 18% in the first two weeks.”
— Developer building with Forge beta

Ready to try a headless form builder for React?

Create a free Forge account, grab your Form ID, and have your first headless form live in under 15 minutes. Free tier includes 4 forms and 100 submissions per month — no credit card required.

Ready to build?

Create your free Forge account. 4 forms, 100 submissions/month, forever free.

Start Free