Why the HTML snippet does not work here
You need your key first.
React builds the DOM itself rather than parsing your markup as HTML, and a <script> element created that way is never executed by the browser. That is a rule of the platform, not a React bug, and it fails quietly: the tag is in the page when you inspect it, nothing is logged, and the div stays empty.
So the script has to be added by code — either your own, or the framework’s. Both versions are below, and both mount exactly the same form.
The component
This works in any React app, with or without a framework. Save it, put your key in it, and render it where you want the form.
ProposalRequestForm.tsx
'use client';
import { useEffect, useRef } from 'react';
export function ProposalRequestForm() {
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) return;
mounted.current = true;
const s = document.createElement('script');
s.src = 'https://hoacrew.com/embed/v1.js';
s.async = true;
document.body.appendChild(s);
}, []);
return (
<>
<div
data-hoacrew-proposal-form
data-key="hoac_embed_your_key_here"
>
{/* Plain HTML, so it is there if the loader is blocked or scripting
is off. The loader empties this div before it inserts the form. */}
<a href="https://hoacrew.com/embed/w/hoac_embed_your_key_here">Request a proposal</a>
</div>
</>
);
}The useRef guard is doing real work: React’s StrictMode runs effects twice in development, and without it you get two copies of the loader on the page. The same guard covers a component that unmounts and remounts.
The attribute is written bare in JSX, which React renders as data-hoacrew-proposal-form="true". That is the same attribute the loader looks for, so nothing else has to change.
Register both spellings of your address.
acme-lawns.com and www.acme-lawns.com are two different places. If your site answers on both, list both on the plugin page. The same goes for http:// against https://. This is the reason behind almost every form that never appears.Next.js, with next/script
Inside Next.js this is the tidier version: the framework keeps one copy of the tag however many times the component renders, and loads it after the page is interactive.
app/components/ProposalRequestForm.tsx
'use client';
import Script from 'next/script';
export function ProposalRequestForm() {
return (
<>
<div data-hoacrew-proposal-form data-key="hoac_embed_your_key_here">
{/* Plain HTML, so it is there if the loader is blocked or scripting is
off. The loader empties this div before it inserts the form. */}
<a href="https://hoacrew.com/embed/w/hoac_embed_your_key_here">Request a proposal</a>
</div>
<Script src="https://hoacrew.com/embed/v1.js" strategy="afterInteractive" />
</>
);
}'use client' is required — the form is a browser thing and a server component cannot mount it. Everything else is ordinary: drop <ProposalRequestForm /> into any page.
Do not put the script in the root layout.
Route changes, tabs and modals
The loader looks for mount points once, when it runs. Anything that appears afterwards — a client-side route change, a tab panel, a modal — arrives too late for that pass. Ask it to look again:
RouteChangeMount.tsx
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
// React Router instead of Next.js? Use this and read `pathname` off it:
// import { useLocation } from 'react-router-dom';
export function ProposalRequestMount() {
const pathname = usePathname();
// const { pathname } = useLocation();
useEffect(() => {
// The loader has already finished looking by the time a client-side route
// change paints. This tells it to look again; a mount point that already
// has a form is skipped, so calling it twice is harmless.
window.HOAcrewEmbed?.mount();
}, [pathname]);
return null;
}usePathname() is the Next.js App Router hook; on a plain React app with React Router, useLocation() gives you the same string. Either way the effect only needs a value that changes when the route does.
The next/script version usually needs none of this, because the tag mounts with the component. The self-appending version needs it whenever the component is rendered into a page that was already loaded.
window.HOAcrewEmbed and window.gtag are globals TypeScript has never heard of, so in a .tsx file both are a compile error until you declare them. Once, anywhere in your project:
types/hoacrew.d.ts
// types/hoacrew.d.ts — or anywhere in your project's type scope
declare global {
interface Window {
HOAcrewEmbed?: { mount: () => void; version: number };
gtag?: (...args: unknown[]) => void;
}
}
export {};The conversion event
When a request is sent, the mount element fires a bubbling hoacrew:proposal-request-sent event. It carries no visitor data at all, so listening for it never turns your analytics into a holder of somebody else’s contact details.
ProposalRequestTracking.tsx
'use client';
import { useEffect } from 'react';
export function ProposalRequestTracking() {
useEffect(() => {
function onSent() {
// Your own conversion tracking goes here. The event carries no visitor
// data — not a name, not an email — only the fact that a request was sent.
window.gtag?.('event', 'generate_lead');
}
document.addEventListener('hoacrew:proposal-request-sent', onSent);
return () => document.removeEventListener('hoacrew:proposal-request-sent', onSent);
}, []);
return null;
}This one needs the Window declaration from the section above too — gtag is a global your analytics tag defines at runtime and TypeScript cannot see. Swap it for whatever your own tracking uses.
How to tell it worked
- Register your development address on the plugin page —
localhost:3000is understood and stored as anhttp://address, because that is what a dev server serves. - Run the app and open the page. The form is there.
- Send a test, taking more than a few seconds over it: a submission completed unusually fast is filed as suspected spam.
- Open Settings → Website plugin in your HOAcrew console. Your test is in the list.
Remember to register your production and preview addresses before you deploy. A preview deployment on a different address is a different address, and the form will not appear on it until it is on the list.
If your app sets a security policy
Most websites have no Content-Security-Policy and this section does not apply to them. If yours has one — a security plugin added it, your host sets it, or whoever built the site wrote one — it needs to allow two things from us:
The two allowances
script-src https://hoacrew.com frame-src https://hoacrew.com
The first lets the loader script run. The second lets the form’s frame render. Allow the first and forget the second and you get a blank space where the form should be, with a line in the browser’s developer console naming frame-src. Nothing else is needed: the form loads no fonts, no analytics and no other company’s code, so there is no third allowance hiding behind these two.
In Next.js this is usually a headers() entry in your config or a line in your middleware. Either way the two source lists need our address on them.
next.config.mjs
// next.config.mjs
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: "script-src 'self' https://hoacrew.com; frame-src https://hoacrew.com",
},
],
},
];
}If you generate a nonce per request, our loader does not need one — it is an ordinary external script and a host allowance covers it. The snippet contains no inline JavaScript and no inline style, so it never asks you for unsafe-inline.
A link, if you would rather not embed it
Every key also has a plain web address that opens the same form as a full page. Put it behind any button, in an email signature, on a Google Business profile, or on a page you cannot add code to.
Your hosted form
https://hoacrew.com/embed/w/hoac_embed_your_key_here
It is the same form, sending to the same place, and requests from it arrive in your console exactly like the ones from the embedded version.
The same form, somewhere else
WordPress
Block editor, Classic editor, Elementor and Divi.
Squarespace
Every plan has code blocks; Core and above can run one.
Wix
Wix runs pasted code on its own address, so this one is different.
Webflow
Custom code needs a paid plan. Register the webflow.io address too.
Shopify
Theme Customizer, no code editor needed.
A plain HTML site
Two lines, wherever you want the form. A complete worked example.