Skip to content

Embed Forms in Next.js

This guide covers embedding a RocketLead form in a Next.js app using the imperative window.rocketlead.forms.create() API. The widget’s auto-mount (<script async> + [data-rocketlead-form]) doesn’t work cleanly in client-routed apps — you need to wire mount/unmount into your component’s lifecycle.

If you’re new to the widget, skim the SPA Frameworks reference first — this guide is the Next.js-specific application of that.

  • A Next.js app (App Router or Pages Router — both covered below).
  • A RocketLead form that’s published. Drafts return 404 from the public API.
  • The form’s ID — copy it from the form’s embed dialog.

In app/layout.tsx:

import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link rel="preload" href="https://cdn.rocketlead.io/widget.css" as="style" />
<link rel="stylesheet" href="https://cdn.rocketlead.io/widget.css" />
</head>
<body>
{children}
<Script src="https://cdn.rocketlead.io/static/forms/widget.js" strategy="afterInteractive" />
</body>
</html>
);
}

strategy="afterInteractive" loads the script after hydration — safe and consistent across navigations.

app/contact/RocketLeadForm.tsx
'use client';
import { useEffect, useRef } from 'react';
interface Props {
formId: string;
}
export function RocketLeadForm({ formId }: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const handle = window.rocketlead?.forms?.create({
target: ref.current,
formId,
});
return () => handle?.unmount();
}, [formId]);
return <div ref={ref} />;
}
app/contact/page.tsx
import { RocketLeadForm } from './RocketLeadForm';
export default function ContactPage() {
return (
<main>
<h1>Get in touch</h1>
<RocketLeadForm formId="your-form-id" />
</main>
);
}

That’s it. The form mounts on entry, unmounts on navigation away (or component unmount), and re-mounts cleanly on return.

In pages/_document.tsx:

import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html>
<Head>
<link rel="preload" href="https://cdn.rocketlead.io/widget.css" as="style" />
<link rel="stylesheet" href="https://cdn.rocketlead.io/widget.css" />
</Head>
<body>
<Main />
<NextScript />
<script src="https://cdn.rocketlead.io/static/forms/widget.js" defer></script>
</body>
</Html>
);
}

defer is fine here — the document-level script loads once and survives client-side route changes.

Same as App Router — the component code is unchanged:

components/RocketLeadForm.tsx
import { useEffect, useRef } from 'react';
export function RocketLeadForm({ formId }: { formId: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const handle = window.rocketlead?.forms?.create({
target: ref.current,
formId,
});
return () => handle?.unmount();
}, [formId]);
return <div ref={ref} />;
}

Use it in any page (pages/contact.tsx):

import { RocketLeadForm } from '@/components/RocketLeadForm';
export default function ContactPage() {
return (
<main>
<h1>Get in touch</h1>
<RocketLeadForm formId="your-form-id" />
</main>
);
}

Add a global ambient declaration so window.rocketlead is typed:

// types/rocketlead.d.ts (or anywhere in your project)
interface RocketLeadFormHandle {
unmount: () => void;
}
interface RocketLeadCreateOptions {
target: HTMLElement | string;
formId: string;
prefill?: Record<string, string | string[]>;
hideFields?: string[];
}
declare global {
interface Window {
rocketlead?: {
forms?: {
create(options: RocketLeadCreateOptions): RocketLeadFormHandle;
};
};
}
}
export {};

Make sure the file is included in your tsconfig.json’s include array (it usually is by default if it’s under types/ or src/).

Pass UTM parameters or any pre-known data from the URL straight into the form:

'use client';
import { useEffect, useRef } from 'react';
import { useSearchParams } from 'next/navigation';
export function RocketLeadForm({ formId }: { formId: string }) {
const ref = useRef<HTMLDivElement>(null);
const params = useSearchParams();
useEffect(() => {
if (!ref.current) return;
const handle = window.rocketlead?.forms?.create({
target: ref.current,
formId,
prefill: {
utm_source: params.get('utm_source') ?? 'direct',
utm_campaign: params.get('utm_campaign') ?? '',
},
hideFields: ['utm_source', 'utm_campaign'],
});
return () => handle?.unmount();
}, [formId, params]);
return <div ref={ref} />;
}

The user sees a clean form; the submission carries the UTM context. See Prefill cascade for the full precedence rules and Stable Field Names for how prefill keys resolve.

Form events fire as DOM CustomEvents on document. Set up listeners in a Client Component (App Router) or a Pages Router page:

'use client';
import { useEffect } from 'react';
export function FormAnalytics() {
useEffect(() => {
const onSuccess = (e: Event) => {
const detail = (e as CustomEvent).detail;
console.log('Submitted:', detail.submissionId);
// fbq('track', 'Lead', { eventID: detail.submissionId });
};
document.addEventListener('rl:form:submit:success', onSuccess);
return () => document.removeEventListener('rl:form:submit:success', onSuccess);
}, []);
return null;
}

See the full event catalog and the ad-pixel pattern using submissionId.

Don’t call window.rocketlead.forms.create() at the top level of a module — it’ll fail during SSR. Always call it inside useEffect, which only runs on the client.

create() is idempotent on the same target — a second call unmounts the previous instance first. With React Strict Mode (default in Next.js dev), useEffect runs twice on mount; the cleanup-then-remount cycle handles this correctly. If you see duplicate forms, your useEffect dependencies probably aren’t stable — make sure formId is a string literal or useMemo’d, not a fresh object each render.

You’re using the auto-mount pattern (<div data-rocketlead-form>) instead of create(). The auto-scan only runs once on DOMContentLoaded — re-entering the route won’t re-mount. Switch to the create() pattern shown above.

window.rocketlead is undefined at first render. The optional chaining (window.rocketlead?.forms?.create) handles this gracefully — useEffect runs after the script finishes loading on subsequent navigations, so subsequent mounts work. If the FIRST mount is consistently empty, increase the script’s load priority (strategy="beforeInteractive" in App Router) or check the network tab for failed widget.js loads.

No special handling needed. Each <RocketLeadForm formId="..."> instance manages its own target ref, and the widget tracks instances in a WeakMap. Just render as many as you need:

<RocketLeadForm formId="form-a" />
<RocketLeadForm formId="form-b" />

If your Next.js app uses a strict Content Security Policy, you need to allow cdn.rocketlead.io for script-src and style-src, and api.rocketlead.io (or whatever your form API host is) for connect-src.

If your form has Erlaubte Domains configured in Form Settings, add your Next.js production domain (and any preview URLs) to the list. Browser submissions from disallowed origins return 403.