All articles
Next.jsContact FormsMulti-Step FormsMeta CAPICollectform

The Best Next.js Contact Form Is the One You Don't Build

November 4, 202626 min read
The Best Next.js Contact Form Is the One You Don't Build

A Next.js contact form is 82 lines of code.

I counted. I built one in a fresh Next.js 16.4.5 app, following the docs: a Server Action and Zod for validation, plus Resend to send the email. That took three files, two new packages, one API key, and a verified sending domain before the first message could go out.

Then I built the same form with Collectform. Two lines.

I run Collectform, so weigh this accordingly, but here is my claim: if a form sits on a public page, if marketing will want to change it, or if ad spend points at it, the best Next.js contact form is the one you don't build. Not because 82 lines is hard. Because line 83 is where the real work starts.

The short version:

  • Setup: 82 lines across 3 files by hand. With Collectform, 2 lines plus an import, and an 11-line type file you add once in TypeScript.
  • Then it grows: adding one branch and one question per screen took my hand-built form to 178 lines. The Collectform version stayed at 2.
  • The cost: Collectform's script is 54.5 KB compressed. The page ships 189 KB of JavaScript, against 133 KB for the simplest hand-built form and 164 to 176 KB once that form validates in the browser. The form also appears a moment after the page. That is a real tradeoff, and the numbers are below.
  • The payoff: multi-step forms, a visual logic builder, built-in validation, 20 input types, email alerts, Meta and TikTok server-side events and cookie consent, all edited without a deploy.
  • Side by side: it worked next to Server Components, Server Actions, client-side navigation, dynamic routes, static export and a strict CSP. I hit one gotcha, and it has a one-word fix.
  • When to ignore me: anything behind a login or writing straight to your database. Use a Server Action for those.

The hand-built version

This is good code, so read it before I complain about it. Here is the Server Action:

// app/contact/actions.ts
"use server"

import { Resend } from "resend"
import { z } from "zod"

const schema = z.object({
  name: z.string().trim().min(1, "Enter your name"),
  email: z.email("Enter a valid email"),
  message: z.string().trim().min(10, "Tell us a bit more"),
})

export type ContactState = {
  status: "idle" | "success" | "error"
  errors?: { name?: string[]; email?: string[]; message?: string[] }
  message?: string
}

export async function sendMessage(
  _prev: ContactState,
  formData: FormData
): Promise<ContactState> {
  const parsed = schema.safeParse(Object.fromEntries(formData))
  if (!parsed.success) {
    return { status: "error", errors: z.flattenError(parsed.error).fieldErrors }
  }

  const { name, email, message } = parsed.data
  const resend = new Resend(process.env.RESEND_API_KEY)
  const { error } = await resend.emails.send({
    from: "Contact form <[email protected]>",
    to: "[email protected]",
    replyTo: email,
    subject: `New message from ${name}`,
    text: message,
  })

  if (error) {
    return { status: "error", message: "Something went wrong. Please try again." }
  }
  return { status: "success" }
}

Add a 36-line client component for the fields, error messages and pending state, plus a 5-line page. Validation errors and the failed-send path both worked in the browser.

If you need three fields that will never change, stop reading and ship this. It is the right answer.

It also isn't finished. There is no spam protection. There is no saved copy either, so if the email fails, the message is gone unless you add a database too.

Line 83

Nobody asks for everything at once. It arrives one message at a time.

"Can we add a budget question?" That's a new field, a schema change, a pull request, a review and a deploy.

"Only ask about budget if they pick Enterprise." Now the form has state.

"Long forms look scary on mobile. Can we do one question per screen?" That's step state, a back button, per-step validation, and a progress bar somebody will want restyled.

"Half these phone numbers are fake." Now you're installing a phone library and a country picker.

"Why does Meta report fewer leads than we have in the inbox?" Now you're reading the Conversions API docs: hashing, _fbp and _fbc cookies, event IDs, deduplication against the Pixel.

"Same for TikTok, please." That's a second API with different field names.

"Legal says none of that can run before consent." Now you need a banner, and a queue that holds events until someone clicks Accept.

Every one of those requests is reasonable. None of them is the product you are building.

The contact form is never the hard part. Everything people ask for after it ships is.

The Collectform version

Here is the entire integration. The two lines that matter are the Script tag and the collectform-embed element.

// app/contact/page.tsx
import Script from "next/script"

export default function ContactPage() {
  return (
    <>
      <Script type="module" crossOrigin="anonymous" src="https://cdn.collectform.com/collectform.js" />
      <collectform-embed form-id="YOUR_FORM_ID" />
    </>
  )
}

No "use client", no environment variables, no API route, no package to install. Setup takes about 5 minutes:

  1. Start from a template or a blank form in the no-code editor.
  2. Set your theme and notification email. If you run ads, paste your pixel ID and access token in the Integrations tab.
  3. Hit Publish and copy your form ID from the Share tab.
  4. Paste the 2 lines into your page.

This is that exact embed, running live inside this Next.js blog post. It's in template mode here, so nothing you type is saved.

Next week, when someone wants that budget question, they add it in the editor and hit Publish. The embed loads the published form each time the page loads, so the change is live on the next page load. No commit, no build, no you.

Two attributes that look optional and aren't

type="module" is required because the bundle uses ES imports. Load it as a classic script and the browser throws Cannot use import statement outside a module, and the form never appears.

crossOrigin="anonymous" is one I learned on our own Next.js page. next/script preloads the file without CORS, and that preload doesn't match the module request that follows. Chrome logs a warning and downloads the bundle twice. With the attribute, I got one download and a clean console.

TypeScript

TypeScript doesn't know about custom elements, so without this file next build fails with error TS2339. Add it once and it also type-checks the form-id attribute.

// collectform.d.ts
import type { DetailedHTMLProps, HTMLAttributes } from "react"

declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "collectform-embed": DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
        "form-id": string
      }
    }
  }
}

Line 83, without the pull requests

So I went back to that list and built it.

Not all of it. Just the budget question, the Enterprise branch and one question per screen, plus a back button, a progress bar and validation on every step. The 82 lines became 178 across four files, and Zod moved into the browser.

It works, and I tested it. It also has one branch, four screens and no design, and the next branch is another pull request.

Here's the same list in Collectform. All of it happens in the editor, and the 2 lines on your page never change.

Input types

"Can we add a budget question?" Add a block. There are 20 to choose from:

GroupBlocks
ContactContact info, email, phone number, address, country, website
Text and numbersShort text, long text, number, date
ChoiceSingle choice, multiple choice, dropdown, image choice
RatingScale, rate
ScreensStatement, swipe cards, loader, thank you

Contact info puts first name, last name, phone, email and company on one screen. Depending on the block, you can randomize or alphabetize options, add an "Other" answer, or pick a date format. A loader can tick through a checklist before showing a result, and a thank-you screen can fire confetti or redirect.

Every one of those is a component you'd otherwise install, style and validate yourself.

The logic builder

"Only ask about budget if they pick Enterprise." The logic builder is a visual map of your form. Every question is a node on a canvas. Draw a line from one question to another and it asks for the rule, which reads like a sentence:

When this answer is equal to Enterprise, then jump to Budget.

A question can have several rules, plus an "Otherwise go to" for when none of them match. What you can check depends on the answer:

AnswerConditions
Short text, long text, email, phone, websiteIs equal to, is not equal to, contains, does not contain, starts with, ends with
Multiple choice, image choice, dropdownIs equal to, is not equal to, contains, does not contain
Single choice, countryIs equal to, is not equal to
Number, scale, rate=, !=, >, >=, <, <=
DateIs equal to, is not equal to, is before, is after

A rule can do three things: Jump to any question or ending, Calculate on a variable (add, subtract, multiply, divide or assign), or Set value. Rules can read variables too, so score > 50 can jump to your booking screen. You can also drop any answer or variable into a later question or thank-you screen.

That's how this estimator works. Say "No, I rent" and a rule sends you straight to a polite ending. Say "Yes" and it keeps going. I tried both extremes: under $100 with lots of shade came out at $150 a year, and over $300 with mostly sunny came out at $2100. That's 8 rules, one loader screen and no code.

One honest limit: each rule checks one condition, with no AND/OR groups today. For a compound decision, add to a variable across several questions and branch on the total.

Multi-step forms

"Long forms look scary on mobile. Can we do one question per screen?" That's the default. Every block is its own screen, so you never write step state. The contact form above is only one step because Contact info groups its fields.

Three switches in Settings replace a chunk of my 178 lines:

  • Progress Bar shows visitors how far along they are.
  • Back Button lets them return and change an answer.
  • Autosave progress restores their answers if they leave and come back.

Then there's the one my hand-built version doesn't have at all: partial responses. The response is saved with the first answer and updated as the visitor moves on, so your dashboard has a Partial tab next to Completed.

Someone who quits on step 5 is still there, with everything they answered. A hand-built form only knows about the people who finish.

Here's a 7-step form, embedded the same way as the first one:

Validation

"Half these phone numbers are fake." Validation is a setting on each block, with no schema to write:

BlockWhat you can enforce
Any questionRequired
EmailValid format, plus an optional code sent to the address
Phone numberA valid number for its country
WebsiteA valid URL
Short and long textMaximum characters
NumberMinimum and maximum value
Multiple choice, image choiceAn exact number or a range of selections

I threw bad input at the widget to see what happens:

  • 123 as a phone number: Invalid phone number
  • 9 in a 1-to-5 number field: Number must be between 1 and 5
  • Seven characters in a five-character field: Must contain at most 5 character(s)
  • not a url as a website: Please enter a valid website URL
  • One pick where two were required: Select at least 2 choices

Each time, the form stayed on that step until the answer was fixed. These checks run in the browser, so if your data needs a server-side check too, add one in your webhook handler.

Now count the diff in your Next.js repo for all of that.

Zero lines.

Your page has 2 lines of code. They don't change when the form does.

The numbers

I wanted data, not opinions. Every version ran as a production build (next build, then next start) on Next.js 16.4.5 and React 19.3.1, in Chrome, with cold caches. Sizes are compressed, as served: gzip from next start for Next.js files, and Brotli from our CDN for ours. These come from my machine and my connection, so yours will differ.

Hand-builtCollectform
Code you write82 lines, 3 files2 lines plus an import
New npm packages20
Secrets to manage1 API key0
Page still prerendered as staticYesYes
JavaScript on the page, compressed133 to 176 KB188.8 KB
Form visibleIn the first HTML1.2 s median (skeleton at 0.5 s)
Layout shift (CLS)00.016, or 0.002 with reserved height
Add a questionPull request and deployEdit and Publish
Multi-step and conditional logic178 lines for one branchBuilt in
Input types and validationYou build them20 types, built in
Meta and TikTok server-side eventsYou build itBuilt in
Cookie consent bannerYou build itBuilt in

Hand-built wins every performance row. It loses every row after them.

Script size, in full

That JavaScript row needs context, because Next.js ships 132 KB before you write a single form.

PageCode you writePackages addedTotal JavaScript
No form at allNone0132.4 KB
Hand-built, validated on the server only82 lines2133.2 KB
Hand-built multi-step, one branch, validated in the browser178 lines2163.5 KB
Hand-built with React Hook Form and Zod79 lines4175.5 KB
Collectform2 lines0188.8 KB

The simplest version stays small because Zod and Resend only run on the server, but every error then needs a round trip. Validate in the browser and Zod comes along, which shrinks the gap with Collectform from 56 KB to between 13 and 25 KB. That's before you add a UI library or a date picker.

Collectform's script is 54.5 KB compressed (198 KB uncompressed): collectform.js at 41.2 KB, plus three helper files. Everything else in that 188.8 KB is Next.js, including 1.9 KB for next/script itself.

Some more detail on the performance rows, because the summary flatters me slightly:

  • More code loads only when a form needs it. Phone validation adds about 45 KB when a visitor reaches it, and swipe cards add about 38 KB. Forms without them never download that code.
  • The JavaScript isn't the whole bill. This form also pulled about 82 KB of other files: the theme's font (24 KB), the form's data (2 KB) and a country list for the phone field (56 KB).
  • Timing varies. The 1.2 seconds is the median of 9 runs, and the worst was 3.2 seconds. On simulated slow 4G with a 4x CPU slowdown, the form was ready in about 2.1 seconds.
  • First paint doesn't suffer. The script loads after hydration, so first contentful paint matched a page with no form at all. But if the form is the largest thing on your page, your Largest Contentful Paint moves with it.

How Collectform works side by side with Next.js

The embed isn't trying to be your framework. It's an HTML element. React renders the tag, the browser upgrades it, and the form lives inside its own Shadow DOM. Next.js keeps doing everything Next.js does.

Next.js keepsCollectform handles
Routing, layouts and metadataThe form's steps, logic and validation
Your landing page, design and copyThe form's theme and content
Server Components and data fetchingStoring submissions
Auth, and signed-in forms with Server ActionsEmail alerts and automatic replies
Your database and business logicMeta and TikTok events, cookie consent
Deploys for your codePublishing form edits, with no deploy

I tested each of the setups below, and I'll tell you what happened.

It stays in a Server Component

The page above has no "use client", and next build still marked the route as static. To React, a custom element is just HTML. The same code works in the Pages Router, with nothing to add to _app or _document.

Load the script once in a layout

If several pages have forms, move the script up a level:

// app/(marketing)/layout.tsx
import Script from "next/script"

export default function MarketingLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <Script type="module" crossOrigin="anonymous" src="https://cdn.collectform.com/collectform.js" />
      {children}
    </>
  )
}

Now any page under that layout only needs the collectform-embed element. I clicked between two pages with next/link: no full reload, one script download, and each form mounted and cleaned up properly. Two forms on one page also worked, again with one download.

A different form on every route

The form ID is a plain string, so it fits normal Next.js routing:

// app/services/[service]/page.tsx
import { notFound } from "next/navigation"
import Script from "next/script"

const FORMS: Record<string, string> = {
  coaching: "FORM_ID_ONE",
  consulting: "FORM_ID_TWO",
}

export function generateStaticParams() {
  return Object.keys(FORMS).map((service) => ({ service }))
}

export default async function ServicePage({
  params,
}: {
  params: Promise<{ service: string }>
}) {
  const { service } = await params
  const formId = FORMS[service]
  if (!formId) notFound()

  return (
    <>
      <Script type="module" crossOrigin="anonymous" src="https://cdn.collectform.com/collectform.js" />
      <collectform-embed form-id={formId} />
    </>
  )
}

Both routes prerendered as static HTML, and each loaded its own form.

Swapping forms on the client? Use key

This is the gotcha. Changing form-id from client state does not load the new form, and the old one stays on screen. Give the element a key so React replaces it:

<collectform-embed key={formId} form-id={formId} />

With key, the form swapped immediately in my test.

Right next to your own Server Actions

I put a newsletter form using a Server Action on the same page as the embed. I typed into the embed, submitted the newsletter form, then called router.refresh().

The text I had typed survived both. React re-renders the tag, but it doesn't touch what's inside the shadow root, so your app's updates won't wipe a half-finished form.

Pass context with a normal link

<Link href="/contact?plan=pro">Talk to sales</Link>

The form reads the page's query string and stores each parameter with the response as a hidden field, UTMs included, and you'll see them in your dashboard. You can limit which ones are kept in the form's Hidden fields settings.

Receive submissions in your own app

Every submission is saved in your Collectform dashboard. If you also want it in your database, add a webhook in the Integrations tab and point it at a Route Handler. Add a custom header named x-webhook-secret, then check it:

// app/api/collectform/route.ts
export async function POST(request: Request) {
  if (request.headers.get("x-webhook-secret") !== process.env.COLLECTFORM_WEBHOOK_SECRET) {
    return new Response("Unauthorized", { status: 401 })
  }

  const { form, response } = await request.json()

  // Save the lead, update your CRM, kick off a job. This part is your code.
  await saveLead({ formId: form.id, responseId: response.id, answers: response.answers })

  return Response.json({ received: true })
}

The handler receives this:

{
  "event": "response.completed",
  "timestamp": "2026-11-04T17:20:37.712Z",
  "form": { "id": "form123", "title": "Contact" },
  "response": {
    "id": "resp123",
    "submittedAt": "2026-11-04 10:00:00.000Z",
    "answers": [
      {
        "blockId": "b1",
        "blockType": "contactInfo",
        "question": "Your details",
        "value": { "firstName": "Ada", "email": "[email protected]" }
      },
      {
        "blockId": "b2",
        "blockType": "singleChoice",
        "question": "Budget",
        "value": "$5k+"
      }
    ],
    "variables": { "score": 7 }
  }
}

Three things to know before you rely on it:

  • It's sent once. There's a 10-second timeout and no retries, so respond quickly and treat the dashboard as your source of truth.
  • Make it safe to repeat. As with any webhook, store response.id with a unique constraint so a repeat delivery can't create a duplicate lead.
  • Hidden fields aren't in the payload. They're in the dashboard, but not in this JSON today.

If you'd rather not write a handler at all, there's a Zapier integration too.

Send people to your own thank-you page

An end screen can redirect to any URL, so https://yoursite.com/thanks can be a normal Next.js route. It's a full page load rather than a client-side transition. There's no JavaScript callback on submit today, so the webhook and the redirect are your two hooks.

Gate your other pixels with the same banner

Turn on the Cookie consent setting and the form shows its own banner. Your app can listen to the answer:

// components/consent-listener.tsx
"use client"

import { useEffect } from "react"

export function ConsentListener() {
  useEffect(() => {
    const onConsent = (event: Event) => {
      const { marketing } = (event as CustomEvent<{ marketing: boolean }>).detail
      if (marketing) {
        // Load your own pixels here.
      }
    }

    window.addEventListener("collectform:consent", onConsent)
    return () => window.removeEventListener("collectform:consent", onConsent)
  }, [])

  return null
}

Accept gave me marketing: true, and Reject gave me marketing: false. The event fires at the moment the visitor answers, not on later page loads, so save the answer yourself if you need it across visits.

Tailwind, shadcn/ui and dark mode

The form renders in a Shadow DOM, so your Tailwind classes can't reach in and its CSS can't leak out. This site runs Tailwind and shadcn/ui, and the form above isn't affected by either. You style it from the Design tab in the editor, with your own colors, fonts and corner radius.

The same wall has a downside. next-themes can't flip the form, so if your site has a dark mode toggle, the form keeps the theme you set in the editor. Pick one that looks right in both.

Static exports

With output: "export" there's no server, so Server Actions are out. next build stops with this:

Server Actions are not supported with static export.

The embed doesn't care. I exported the same page, served the out folder with a plain static file server, and the form loaded with a clean console. Route Handlers that rely on the request aren't available in an export either, so point any webhook at a different server.

Content Security Policy

If you run a CSP, add these sources to your existing policy:

script-src   https://cdn.collectform.com
style-src    'unsafe-inline' https://fonts.googleapis.com
font-src     https://fonts.gstatic.com https://cdn.collectform.com
connect-src  https://api.collectform.com https://collectform.com https://venkatvidyut.github.io

With these in place on a production build, a form with a phone field loaded with zero violations.

Removing two of them showed what they do. Without 'unsafe-inline' in style-src, the form renders unstyled, because it injects its stylesheet into its Shadow DOM. Without the github.io host, the phone field's country list has no data. The collectform.com entry is where tracking events go.

You'll also need img-src to allow wherever your form's images live. If you turn on the browser Pixel, add Meta's hosts as well: connect.facebook.net in script-src, and www.facebook.com in img-src and connect-src.

Keep the layout still

The form arrives after hydration, so reserve its height to stop the content below from jumping:

<div style={{ minHeight: 520 }}>
  <collectform-embed form-id="YOUR_FORM_ID" />
</div>

I reserved 520px for a form that rendered 415px tall, and CLS went from 0.016 to 0.002. Both are under Google's 0.1 "good" threshold, but my test page was short, so expect a bigger difference with more content below the form. Measure your own form, on mobile too.

For a form far down the page, strategy="lazyOnload" on the Script tag also works.

Not just contact forms

The same 2 lines work for any form you can build in the editor, so "Next.js contact form" undersells it. Here's what else I'd use it for:

  • Quote and estimate requests. Calculations turn answers into a number on screen before the contact step.
  • Consultation and demo requests. Conditional logic sends a poor fit to a short, polite ending instead of your calendar.
  • Lead magnets. Ask a couple of qualifying questions, then deliver the guide.
  • Webinar and event registrations.
  • Surveys, NPS and client feedback. Scales, ratings and swipe cards all work well here.

Same builder, same logic, same validation, and the same 2 lines on your page.

To be clear about scope, Collectform is the form, not your site. You still own the page, the design and the funnel around it.

Where it doesn't belong

Don't use it for login, signup, checkout or account settings. Don't use it for anything behind auth, or anything that has to write to your database in the same request. Use Server Actions for those. The Next.js forms guide is excellent.

So a real app ends up with both, which is the whole point. Server Actions for the product. Collectform for the public forms that marketing keeps wanting to change.

The part that's painful to build: ad tracking

If you don't run ads, skip this section.

To do this by hand in Next.js, you'd write a Route Handler that calls Meta's Conversions API. You'd hash emails and phone numbers with SHA-256, forward the _fbp and _fbc cookies, and share an event ID with the browser Pixel so Meta counts each conversion once. Then you'd do it again for TikTok's Events API, and hold all of it until the visitor consents.

In Collectform, you paste your pixel ID and access token into the Integrations tab. The events go out from the server, with no tracking code in your repo.

Meta's own reported figure is that advertisers running Conversions API for web events saw 17.8% lower cost per result than those without it. That's their internal data, so hold it loosely. The mechanism isn't controversial: a browser extension can't block a server-to-server call.

Three details matter in a Next.js app:

  • It's a web component, not an iframe. The form runs in your page, so it can read your URL and first-party cookies. An iframe from another origin can't do that without extra work. I wrote up the wider differences.
  • Consent is enforced, and I checked. With the banner on, clicking Accept sent the tracking request and clicking Reject sent nothing. You still receive the submission either way.
  • It plays nicely with an existing Pixel. If your layout already loads Meta's Pixel, the form reuses window.fbq and sends only to its own pixel ID. Just don't also fire your own Lead event for the same form, or you'll count it twice.

How it compares

I checked each vendor's own site in November 2026. Pricing pages change, so check before you decide.

OptionWho builds the form UIMulti-step and logicServer-side Meta and TikTokStarting price
Server Action and ResendYouYou build itYou build itHosting, plus Resend at $0 for 3,000 emails/month
FormspreeYou, with @formspree/reactYou build itNot listed on its plans page$0 for 50 submissions/month, paid from $10/month billed yearly
TallyTally, in an iframeYesMeta Pixel on Pro, no server-side API listed$0 plan, Pro at $24/month
TypeformTypeform, in an iframeYesMeta Pixel listed, no server-side API listedPaid plans, see its pricing page
CollectformCollectform, as a web componentYesYes, both$20/month billed yearly, after a 7-day trial

If budget decides this, Collectform doesn't win the table. Tally and Formspree both have $0 plans, and a Server Action costs you time rather than a subscription.

  • Use a Server Action when the fields are few and stable, you run no ads, and a developer owns the form. Always use one behind a login.
  • Use Formspree when you want full control of the markup and just need somewhere for submissions to land.
  • Use Tally or Typeform when you want a hosted form or survey and ad attribution doesn't matter.
  • Use Collectform when the form is public, non-developers need to edit it, multi-step matters, or you're paying for the traffic.

That last group is who I built this for.

A few honest caveats

It's heavier. The page ships 189 KB of JavaScript against 133 to 176 KB by hand, plus the other files. If your page is a performance showcase, a hand-built form will always beat this.

It isn't server-rendered. A Server Action form is in the HTML from the first byte and supports progressive enhancement. The embed needs JavaScript and shows a skeleton first. It also depends on our API, so if that's down, your form is down.

There's no file upload and no CAPTCHA. Neither exists today. If you need attachments or bot filtering right now, this isn't your form yet.

Your page can't react to a submit. There's no JavaScript callback, only the webhook and the redirect. A hand-built form can do whatever it likes the moment someone hits send.

Your submissions live with us. They stay out of your database unless you add the webhook, and that webhook doesn't retry.

It has limits. Pro includes 300 leads a month and 10 active forms. Team includes 1,000 and 25. Your own Server Action has no such caps.

The rule of thumb

If a non-developer will ever ask to change it, embed it.

If it writes to your database behind a login, build it.

Keep Next.js for your app. Embed the form. Go build the thing only you can build.


Collectform is a multi-step lead form with Meta and TikTok server-side tracking that you embed on the page you already own. Plans start at $20/month billed yearly, and every plan starts with a 7-day trial. See the Next.js setup or browse the templates.

Frequently asked questions

The lead tracking form platform built for paid traffic.

Replace your weak form with a high-converting, multi-step lead form, with server-side tracking built-in. Drop it into the funnel you already have with one line of code.

7-day free trial ยท No credit card ยท Cancel anytime