@se-studio/hubspot
v17.5.10
Published
HubSpot tracking and API-driven form rendering for Next.js marketing sites
Maintainers
Readme
@se-studio/hubspot
HubSpot tracking and API-driven form rendering for Next.js marketing sites.
Features
HubSpotAnalyticsAdapter—AnalyticsAdapterfor HubSpot tracking code (SPA Mode B page views, custom behavioural events)HubspotDynamicForm— React renderer from HubSpot Marketing Forms API definitions (method="post"action="#"so a pre-hydration native submit cannot put field values in the URL)- UTM / marketing hidden fields — auto-fills empty hidden form fields from URL query params and first-touch
sessionStorage(utm_source,utm_medium,utm_campaign,utm_term,utm_content, plus any query key that matches a hidden field name) createHubspotFormExternalRenderer— CMS external component factory (externalComponentType: "Hubspot form")- Per-form fetch —
GET /marketing/v3/forms/{formId}with Next.jsunstable_cache(no bulk form download)
Installation
pnpm add @se-studio/hubspotEnvironment variables
| Variable | Purpose |
|----------|---------|
| HUBSPOT_PORTAL_ID | Portal ID for tracking script and form submissions |
| HUBSPOT_PAT | Private app token with forms scope (server-only, form definition fetch) |
Analytics (with GTM)
HubSpotAnalyticsAdapter implements HubSpot’s third-party consent banner API:
- Sets
window.disableHubSpotCookieBanner = truebefore injectingjs.hs-scripts.com - Pushes
_hsp.setHubSpotConsent({ analytics, advertisement, functionality })on everyinitialize/updateConsent(HubSpot does not persist this — re-assert every page / change) - By default mirrors analytics consent to all three categories; pass
resolveHubSpotConsentorupdateConsent({ advertising })for explicit flags _hsq.doNotTrackis opt-in viahardDoNotTrack: true(stricter than official decline — blocks anonymized page views)
ConsentAwareAdapter({ syncConsent: true }) already forwards analytics into updateConsent, so HubSpot category sync runs inside the HubSpot adapter when getConsent / updateConsent is used.
import { AnalyticsPageTracker, AnalyticsProvider, CompositeAnalyticsAdapter, ConsentAwareAdapter, GoogleTagManagerAdapter, createConsentGetter } from '@se-studio/core-ui';
import { AbTestReporter } from '@se-studio/ab-testing/components';
import { HubSpotAnalyticsAdapter, createHubSpotBootstrapScript } from '@se-studio/hubspot';
import Script from 'next/script';
const hasConsent = createConsentGetter({
model: 'opt-out',
honorGlobalPrivacyControl: true,
honorDoNotTrack: true,
source: () => /* site cookie / CMP; undefined = model default */ undefined,
});
const adapter = new ConsentAwareAdapter(
new CompositeAnalyticsAdapter([
new GoogleTagManagerAdapter({
containerId: process.env.GTM_TAG!,
// omit consentDefault when a CMP owns Consent Mode; set only for adapter-owned default
consentDefault: 'granted',
}),
new HubSpotAnalyticsAdapter({
portalId: process.env.HUBSPOT_PORTAL_ID!,
getConsent: hasConsent,
// Optional: hardDoNotTrack: true — also pushes `_hsq.doNotTrack` (zero anonymized hits)
}),
]),
'analytics',
() => hasConsent(),
{ syncConsent: true },
);
adapter.initialize();
// layout.tsx — before HubSpot script loads (no args = SSG-safe; uses window.location.pathname).
// Bootstrap also sets `disableHubSpotCookieBanner` and initialises `_hsp` / `_hsq`.
<Script id="hubspot-bootstrap" strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: createHubSpotBootstrapScript() }} />
<AnalyticsProvider adapter={adapter}>
<AnalyticsPageTracker />
<AbTestReporter />
{children}
</AnalyticsProvider>CMS external component
Add "Hubspot form" to your externalComponent enum. data JSON:
{
"formId": "hubspot-form-guid",
"portalId": "optional-override",
"submitButtonText": "Optional label",
"submittingButtonText": "Optional pending label",
"submitErrorText": "Optional error copy",
"submitTooFastText": "Optional wait-and-retry copy",
"hiddenFields": { "campaign": "value" }
}import { defineExternalComponent } from '@se-studio/core-ui';
import { createHubspotFormExternalRenderer } from '@se-studio/hubspot/external';
import { Section } from '@/framework/Section';
export const HubspotFormRegistration = defineExternalComponent({
name: 'Hubspot form',
renderer: createHubspotFormExternalRenderer({
wrap: ({ information, children, componentName }) => (
<Section information={information} componentName={componentName}>
{children}
</Section>
),
// Optional site-level defaults; CMS `data.submittingButtonText` / `data.submitErrorText` / `data.submitTooFastText` win
submittingButtonText: 'Sending…',
submitErrorText: 'Please try again later.',
submitTooFastText: 'Please wait a moment and try again.',
}),
});Hidden UTM / campaign fields
If the HubSpot form defines hidden fields whose internal names match query params (e.g. utm_source, utm_medium, utm_campaign, utm_term, utm_content), HubspotDynamicForm fills them on mount from:
- HubSpot form defaults (if any)
- First-touch
sessionStorage+ current URL (URL wins for keys present on the form page) - CMS
data.hiddenFields/data.initialValues(highest — static overrides win)
First-touch is stored under sessionStorage key se_studio_marketing_params so a visitor who lands with UTMs on / and submits on /contact-us/ still attributes correctly.
Sites that fork the form renderer should call the same helpers (do not reimplement):
import {
applyMarketingParamsToHiddenFields,
buildDefaultValues,
captureMarketingParams,
} from '@se-studio/hubspot';
// Optional: call once in a root client provider so multi-page capture runs before the form mounts
captureMarketingParams();
// When seeding form state (same order as HubspotDynamicForm):
setFormData(buildDefaultValues(formDefinition, hiddenFields, initialValues));
// or applyMarketingParamsToHiddenFields(formDefinition, baseDefaults)Submissions already send Forms API context (hutk, pageUri, pageName) via useHubspotSubmit.
Post-submit behaviour
These forms are not HubSpot’s native embed script — the package rebuilds fields from the Marketing Forms API and posts via the Forms Submit API. Redirect / thank-you must be handled in our client:
| Priority | Source | Behaviour |
|----------|--------|-----------|
| 1 | redirectUrlOverride prop | CMS override (e.g. Contentful externalUrl or data.successRedirectUrl) |
| 2 | Submit API redirectUri | Includes HubSpot conditional redirects when returned |
| 3 | Form definition postSubmitAction type redirect_url | Configured in HubSpot form editor |
| 4 | inlineMessage / thank_you | Inline message on the form |
Navigation: external absolute URLs use window.location.assign; same-origin paths use Next.js router.push. Full redirect URLs are preserved (hosts are not stripped).
onSuccess: side-effect only (analytics, swap to CMS extraCopy). It does not suppress thank-you or redirect. When a redirect is configured, navigation wins after the callback. Do not send onSuccess field values (email included) to analytics.
onError: optional. HubSpot HTTP errors (!ok, status: "error", or errors[]) and anti-bot timing skips fail the submit instead of showing a thank-you. The standard form disables the submit button while in flight (isSubmitting), shows submitErrorText or submitTooFastText (default English), and keeps field values so the visitor can retry. Pass submittingButtonText / submitErrorText / submitTooFastText on HubspotDynamicForm, or via CMS JSON data / renderer options — not new Contentful fields. Too-fast copy: submitTooFastText → submitErrorText → Please wait a moment and try again. After a successful submit that leaves the page, the button stays pending until navigation, a 10s safety timeout, a bfcache pageshow, or a thrown router.push. Same-path or hash redirects do not keep the lock. Blank or whitespace-only submittingButtonText / submitErrorText / submitTooFastText fall back to the English defaults. If navigation takes longer than 10s the button re-enables (safety valve, a second submit is possible in that window). Do not forward the raw HubSpot onError string or any field values to analytics.
Core can emit form_view, form_start, form_submit, and form_error (category/code only) through useAnalytics when you pass formAnalytics={true}. Default is off, so a package bump does not add events. form_view fires once when the form first enters the viewport (threshold 0.5). Import helpers from @se-studio/core-ui/form-analytics. Pass cmsEntryId for cms_entry_id.
<HubspotDynamicForm
portalId={portalId}
formDefinition={formDefinition}
redirectUrlOverride={cmsExternalUrl} // optional CMS override
submittingButtonText="Sending…" // optional; default "Submitting…"
submitErrorText="Please try again later." // optional
submitTooFastText="Please wait a moment and try again." // optional
onSuccess={() => setShowExtraCopy(true)} // optional; redirect still runs if set
onError={() => setShowRetryHint(true)} // optional UI
formAnalytics // opt in to form_view / form_start / form_submit / form_error
/>Cache revalidation
When a form changes in HubSpot, revalidate:
import { revalidateTag } from 'next/cache';
import { hubspotFormTag } from '@se-studio/hubspot/server';
revalidateTag(hubspotFormTag(formId), { expire: 0 });