HOAcrew

Add the form to React or Next.js

A script tag written in JSX is put into the page and never executed, so the HTML snippet leaves you with an empty div and nothing in the console. Here are the two versions that work.

Where the code goes
a component
How the form arrives
Inside your page
the visitor never leaves your site
What you paste
Two lines of HTML
Addresses per key
10
list every spelling your site answers on

Why the HTML snippet does not work here

You need your key first.

It is on the plugin page in your console, under Settings, Website plugin. That page is also where you list the web addresses your form is allowed to appear on, and it will not work anywhere you have not listed. The overview walks through getting one.

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.

To a browser, 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.

It costs nothing on pages without a form, but it also does nothing there. Keeping the script beside the mount point means the pair travels together and cannot be half-removed later.

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

  1. Register your development address on the plugin page — localhost:3000 is understood and stored as an http:// address, because that is what a dev server serves.
  2. Run the app and open the page. The form is there.
  3. Send a test, taking more than a few seconds over it: a submission completed unusually fast is filed as suspected spam.
  4. 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.

The same form, somewhere else