@se-studio/hubspot
v17.0.1
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- 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)
import { AnalyticsPageTracker, AnalyticsProvider, CompositeAnalyticsAdapter, ConsentAwareAdapter, GoogleTagManagerAdapter } 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 adapter = new ConsentAwareAdapter(
new CompositeAnalyticsAdapter([
new GoogleTagManagerAdapter({ containerId: process.env.GTM_TAG! }),
new HubSpotAnalyticsAdapter({ portalId: process.env.HUBSPOT_PORTAL_ID! }),
]),
'analytics',
hasConsent,
);
// layout.tsx — before HubSpot script loads (no args = SSG-safe; uses window.location.pathname)
<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",
"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>
),
}),
});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.
<HubspotDynamicForm
portalId={portalId}
formDefinition={formDefinition}
redirectUrlOverride={cmsExternalUrl} // optional CMS override
onSuccess={() => setShowExtraCopy(true)} // optional; redirect still runs if set
/>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 });