UI extensions
A HubSpot UI extension is a React component HubSpot renders for you inside a sandboxed iframe on a CRM record or a help desk conversation. This guide builds Email Guard's email-health card end to end: declaring the surface, reading CRM context and record properties, fetching a live deliverability verdict from the email-health card backend on your Worker through the generated client, and shipping a build that survives HubSpot's sandbox rules and bundle budget.
TL;DR — A UI extension is a React component HubSpot renders in a sandboxed iframe on CRM records or help desk conversations. With HS-X you declare it in hsx.config.ts next to your app, link it to a card backend so the deploy validates the pair and generates a typed client, read record properties through HubSpot’s actions, and hot-reload the whole loop with hs-x dev. hs-uix is an optional component layer on top. The worked example is Email Guard’s email-health card.
Before you begin
A HubSpot UI extension is not a normal React app. HubSpot renders it inside a cross-origin iframe with a strict CSP, no DOM, no window, no fetch of your choice, and a narrow allow-list of npm packages that the bundler will accept. Anything you’d reach for in a typical SPA — axios, react-hook-form, react-router, framer-motion, direct document access — is either blocked at build time or silently no-ops at runtime. The mental tax this imposes is real, and it’s the single biggest reason developers bounce off UI extensions on their first try.
HS-X cushions the sandbox in two ways. First, hs-uix ships components that are pre-vetted for the iframe: they only use the primitives HubSpot’s renderer accepts (Tile, Text, Box, Button, Table, etc.) and they handle the layout idioms (no flexbox gap, no CSS variables in inline styles) that fail silently otherwise. Second, the hs-x CLI generates the card’s HubSpot metadata from the card({ … }) declaration in hsx.config.ts and validates the project before upload; HubSpot’s remote build then compiles the card from src/app/cards/. You’ll still hit the sandbox rules occasionally, but you’ll hit them in the build verdict with a useful error instead of at runtime with a blank card.
What you can and can’t reach for
A short cheat sheet for the packages that come up most often. Save yourself the lookup:
| Want to… | Allowed | Use instead |
|---|---|---|
| Make an HTTP request from the iframe | No bare fetch, axios, ky | hubspot.fetch to your Worker’s card-backend route (or the generated _hsx-backend.ts client); hs-x deploy allow-lists the Worker origin in permittedUrls.fetch for you |
| Route between views | No react-router, no useNavigate | <Modal> / <Panel> attached through a Button’s overlay prop, or actions.openIframeModal |
| Build a form | No react-hook-form, no formik | hs-uix form components or raw Input / Select with useState |
| Animate | No framer-motion | hs-uix transition components or LoadingSpinner |
| Access the DOM | No document, no refs to native nodes | Compose with hs-uix / @hubspot/ui-extensions primitives only |
| Read CRM data | context.crm gives the record id and object type only | actions.fetchCrmObjectProperties([...]) for property values; useAssociations from @hubspot/ui-extensions/crm for associations |
The four surfaces
Where the extension renders dictates which fields context exposes, which actions are available, and which UI affordances you get for free. Pick the surface first, then write the component:
crm.record.tab— a full-width tab on a contact / company / deal / ticket / custom object record.context.crmhas the record id and object type; properties come fromactions.fetchCrmObjectProperties. Best for rich layouts that need horizontal room.crm.record.sidebar— a narrow card in the right-rail of a record. Samecontext.crmas the tab, but you only have ~320 px of width. Best for at-a-glance facts and one or two actions.crm.preview— the popover that appears when a user hovers an associated record. Renders fast or not at all; do the absolute minimum on mount and defer everything else.helpdesk.sidebar— the right-rail on a help desk conversation.contextexposes the active ticket plus the latest message. Same width constraints as the CRM sidebar.
App-level settings pages and app home are separate HubSpot extension points, not card locations — HS-X cards do not target them today. If your app needs a settings page, that is its own surface with its own setup.
The surface is the location you pass to card({ … }) in hsx.config.ts. Changing it is a one-field edit, but the available context shape changes with it, so pick before you start wiring data.
Declare an extension and pick a surface
An HS-X UI extension is a declaration plus a component. The declaration is a card({ … }) entry in the cards array of hsx.config.ts: the surface it renders on, the object types it appears for, and the path to its React entrypoint. The component is a .tsx file under src/app/cards/ that registers itself with hubspot.extend(...). hs-x init --ui-extension scaffolds both (DemoCard.tsx and its declaration); the Getting started guide renamed them into Email Guard’s card. Email Guard’s card is a contact sidebar card (crm.record.sidebar): an at-a-glance verdict belongs in the right rail, not a full-width tab. Changing your mind later is a one-field edit to location.
// hsx.config.ts — the card declaration
import { card, defineApp } from '@hs-x/sdk';
export default defineApp({
// ...name, distribution, auth, scopes...
cards: [
card({
id: 'email-health-card',
name: 'Email health',
location: 'crm.record.sidebar',
objectTypes: ['contacts'],
entrypoint: './src/app/cards/EmailHealthCard.tsx',
backend: 'email-health', // the worker.cardBackend this card calls; wired in step 3
}),
],
});// src/app/cards/EmailHealthCard.tsx
import { Tile, Text, hubspot } from '@hubspot/ui-extensions';
function EmailHealthCard({ context }) {
return (
<Tile>
<Text format={{ fontWeight: 'bold' }}>Email health</Text>
</Tile>
);
}
hubspot.extend(({ context }) => <EmailHealthCard context={context} />);The card directory has its own dependencies: hs-x deploy creates src/app/cards/package.json the first time it sees a card (with @hubspot/ui-extensions, react, and typescript) and never overwrites it afterwards, so anything else the card imports (step 4 adds hs-uix) goes into that file by hand after the first deploy.
What the two files do
- The
.tsxfile is the runtime.hubspot.extend(fn)is the registration call — HubSpot’s renderer invokesfnwith the context object once the iframe mounts, and renders whatever React tree you return. There is exactly onehubspot.extendcall per file; multiple calls register the last one only. - The
card({ … })declaration is the build-time contract. Onhs-x deploy(andhs-x dev), codegen turns it into thesrc/app/cards/email-health-card-hsmeta.jsonHubSpot’s project build expects:locationmaps to one of the four surfaces,entrypointbecomes the/app/cards/EmailHealthCard.tsxpath inside the upload bundle, andobjectTypesis translated to HubSpot’s constants — you write'contacts', the generated file says"CONTACT". You never edit the generated file; the next deploy overwrites it. objectTypesconstrains which records the card appears on. Standard objects take their plural or singular spelling ('contacts','companies','deals','tickets'); a custom object’s type is normalized to UPPER_SNAKE (p_customerbecomesP_CUSTOMER).
Common declaration issues
- “No supported components” on deploy. HubSpot’s build couldn’t find a component registered through
hubspot.extend. Add the call at the bottom of the file with your component inside, and pass the context through:hubspot.extend(({ context }) => <EmailHealthCard context={context} />). - “Couldn’t find the following components: EmailHealthCard.” The
entrypointincard({ … })doesn’t match the.tsxfilename, or the file isn’t undersrc/app/cards/. That directory is the one HubSpot’s upload bundle packages, so a card anywhere else never reaches the build. - Card doesn’t appear in the portal. Check
objectTypesfirst (a card declared for['deals']never renders on a contact record), then confirm the deploy’s HubSpot build printed[ok] email-health-card (CARD); a failed component prints its build error in the same list.
Read CRM context with context.crm
The context object HubSpot hands to your extension is the entire data API for the iframe, and context.crm is smaller than most first-timers expect. context.crm carries exactly two things: objectId (a number) and objectTypeId (a string such as 0-1 for contacts). It does not carry property values. The rest of context is the viewing user (context.user), the portal (context.portal), and the surface (context.location). Property values come from an action, not from context: actions.fetchCrmObjectProperties(['email', 'email_health_status']) returns a promise of the current values, every value a string. It is a real round-trip to HubSpot, so treat it like any other fetch: fire it in an effect, render a placeholder until it lands, and re-call it after actions.refreshObjectProperties() when a save happens elsewhere on the page. For a live subscription instead of a one-shot read, actions.onCrmPropertiesUpdate(['email_health_status'], callback) invokes your callback whenever those properties change. Association data has its own hook: useAssociations from @hubspot/ui-extensions/crm.
import { useEffect, useState } from 'react';
import { Tile, Text, hubspot } from '@hubspot/ui-extensions';
// The values you fetch are yours to type: fetchCrmObjectProperties returns
// Record<string, string>, and HS-X generates no property typings for cards.
interface EmailHealthProps {
readonly email?: string;
readonly email_health_status?: string;
readonly email_health_score?: string;
readonly email_suppressed?: string;
}
function EmailHealthCard({ context, actions }) {
const [props, setProps] = useState<EmailHealthProps | null>(null);
useEffect(() => {
actions
.fetchCrmObjectProperties(['email', 'email_health_status', 'email_health_score', 'email_suppressed'])
.then((values) => setProps(values as EmailHealthProps));
}, [context.crm.objectId]);
if (!props) return <Tile><Text>Loading…</Text></Tile>;
return (
<Tile>
<Text format={{ fontWeight: 'bold' }}>{props.email ?? 'No email on record'}</Text>
<Text>Status: {props.email_health_status ?? 'unknown'}</Text>
<Text>Score: {props.email_health_score ?? '–'}</Text>
<Text>Suppressed: {props.email_suppressed === 'true' ? 'Yes' : 'No'}</Text>
</Tile>
);
}
hubspot.extend(({ context, actions }) => <EmailHealthCard context={context} actions={actions} />);Typing what you fetch
There is no schema-to-iframe typing today. The generated refs.d.ts and _hsx-backend.ts type the route and the id of your card backends, not your property names, and hs-uix does not read your schema either. Type the values you fetch yourself: a small interface next to the component, as above, is enough, and it is the same pattern the card-with-backend example uses. Everything fetchCrmObjectProperties returns is a string (HubSpot serializes numbers, booleans, and dates on the wire), so parse at the edge (Number(props.email_health_score), props.email_suppressed === 'true') rather than trusting the shape.
When a property read is not enough
A stored property is exactly what a sidebar card should render first: the last verdict validate-email wrote, cheap to fetch and instantly meaningful. What the iframe cannot do is produce a fresh one. Re-verifying an address means calling the external verification API with a bearer secret, and neither the secret nor the call belongs in the iframe. The same applies to associated objects, custom queries, and computed aggregations. Use your card backend for those; that is the next step.
Fetch server data through your card backend
The bridge between your iframe and your Worker is a card backend: a capability you declare with worker.cardBackend, served by your Worker at POST /_hsx/cards/<id>, and called from the extension through hubspot.fetch. The handler runs in your Cloudflare account with the portal’s auth context (full HubSpot API access through ctx.hubspot) and is the right place for anything that needs network IO, secret-bearing requests, or aggregation across many records. For Email Guard that’s the email-health backend: it re-verifies the contact’s address against the external API, secret and all, and hands the card a fresh verdict. Two declarations make it work: the backend on the worker, and a backend link on the card so hs-x deploy can validate the pair and generate the client the card imports.
// src/workers/email-guard.ts
import { defineWorker } from '@hs-x/sdk';
const worker = defineWorker('email-guard');
worker.cardBackend('email-health', {
label: 'Email health',
objectTypes: ['contacts'],
// Declaring input types ctx.input and makes the runtime reject a call
// that omits contactId with 400 missing_required_input.
input: { contactId: { type: 'string', required: true } },
async handler({ input, env, hubspot }) {
const contact = await hubspot.crm.objects.contacts.get(input.contactId, {
properties: ['email'],
});
const email = contact.properties.email ?? '';
const res = await fetch('https://api.emailcheck.example/v1/verify', {
method: 'POST',
headers: {
authorization: `Bearer ${String(env.EMAILCHECK_API_KEY)}`,
'content-type': 'application/json',
},
body: JSON.stringify({ email }),
});
const verdict = (await res.json()) as { status: string; score: number };
return { email, status: verdict.status, score: verdict.score, checkedAt: new Date().toISOString() };
},
});
export default worker;// hsx.config.ts — link the card to its backend
import { card, defineApp } from '@hs-x/sdk';
export default defineApp({
// ...name, distribution, auth, scopes...
cards: [
card({
id: 'email-health-card',
name: 'Email health',
location: 'crm.record.sidebar',
objectTypes: ['contacts'],
entrypoint: './src/app/cards/EmailHealthCard.tsx',
backend: 'email-health',
}),
],
});Declaring backend does two things. It makes hs-x check and hs-x deploy fail if the backend id does not exist (card.backend.missing), if its objectTypes do not cover the card’s (card.backend.scope-mismatch), or if the Worker origin is not fetch-permitted (card.backend.permission-missing). And it makes the deploy write src/app/cards/_hsx-backend.ts: a secret-free module exporting one function per linked backend that wraps hubspot.fetch against POST /_hsx/cards/<id> on the deployed Worker origin. You do not need to allow-list that origin yourself: hs-x deploy always adds the Worker origin it just deployed to the app’s permittedUrls.fetch. Declare permittedUrls.fetch only for a custom domain or a second origin.
// src/app/cards/EmailHealthCard.tsx
import { useEffect, useState } from 'react';
import { hubspot, Tile, Text, LoadingSpinner } from '@hubspot/ui-extensions';
import { emailHealth } from './_hsx-backend'; // generated by hs-x deploy
type Verdict = { email: string; status: string; score: number; checkedAt: string };
function EmailHealth({ contactId }: { readonly contactId: string }) {
const [verdict, setVerdict] = useState<Verdict | null>(null);
useEffect(() => {
// Whatever the card sends must sit under `input`; the runtime decodes the
// body as the dispatch payload and drops other top-level keys.
emailHealth<{ ok: boolean; result: Verdict }>({ input: { contactId } }).then((res) => {
if (res.ok && res.data.ok) setVerdict(res.data.result);
});
}, [contactId]);
if (!verdict) return <LoadingSpinner label="Checking deliverability" />;
return (
<Tile>
<Text>{verdict.email}: {verdict.status} ({verdict.score})</Text>
</Tile>
);
}
hubspot.extend(({ context }) => <EmailHealth contactId={String(context.crm.objectId)} />);The generated function returns { ok, status, data }, where ok and status describe the HTTP response and data is the runtime’s envelope, { ok, capabilityId, result }, with result carrying whatever your handler returned. If you would rather hand-write the call, the shape is hubspot.fetch(WORKER + '/_hsx/cards/email-health', { method: 'POST', body: { input: { contactId } } }); the .hs-x/refs.js stub exports the same path and method as typed constants. Either way, HubSpot signs the request and the runtime verifies the signature and the calling portal’s install before your handler runs, so the card never authenticates anything itself.
When to fetch vs when to read a property
A simple rule: if the field is a property on the record itself, read it with actions.fetchCrmObjectProperties; it costs one HubSpot round-trip and no Worker. If the data is computed, associated, or external (other records, third-party APIs, anything that needs a secret like a fresh verification), fetch it from your card backend. The cost is one extra network hop plus the cold start of your Worker (a few milliseconds on a warm isolate, more on a cold one), which is fast but not free.
Common card-backend issues
hubspot.fetchis rejected before it leaves the iframe. HubSpot proxieshubspot.fetchand enforces the app’spermittedUrls.fetchallow-list. The deploy adds the Worker origin automatically, so the usual causes are a stale origin (aworkers.devURL that changed since the last HubSpot upload) or a card calling somewhere other than the deployed Worker. Redeploy so the HubSpot-side metadata catches up.- The Worker answers
404 unknown_card_backend. The id in the URL doesn’t match a declaredworker.cardBackendid. Ids are kebab-case and unique per worker; withbackenddeclared,hs-x checkcatches the typo before deploy and the generated client makes it a compile error. - The handler receives an empty
input. The card posted its fields at the top level of the body instead of underinput. Move them underinput(the generated client’s argument is the whole body, so pass{ input: {...} }). - The handler can’t reach HubSpot data.
ctx.hubspotresolves the calling portal’s token; a portal that never installed the app has no token to resolve, and the runtime refuses the call before the handler runs. Test against a portal with a real install.
Compose with higher-level hs-uix components
The base @hubspot/ui-extensions package gives you primitives: Tile, Box, Text, Button, Table, Input. They render correctly inside the sandbox and they match HubSpot’s design tokens, but they’re low-level. Building a labeled property list or a section header from scratch every time is the kind of repetition hs-uix exists to remove.
hs-uix is an optional third-party layer, not part of HS-X or the scaffold. It lives in two places when you use it: bun add -D hs-uix at the root for local type-checking, and a "hs-uix": "^2.2.0" entry in src/app/cards/package.json for HubSpot’s remote build. hs-x deploy creates that package.json the first time it sees a card (with @hubspot/ui-extensions, react, and typescript) and never overwrites it afterwards, so add the dependency after the first deploy, or create the file with those four entries yourself. hs-uix/common-components ships SectionHeader and KeyValueList; heavier components have their own subpaths (hs-uix/datatable, hs-uix/form, hs-uix/kanban, and others). Check the package’s own README for each component’s props; this guide only leans on the two below.
import { useEffect, useState } from 'react';
import { SectionHeader, KeyValueList } from 'hs-uix/common-components';
import { Tile, Statistics, StatisticsItem, hubspot } from '@hubspot/ui-extensions';
function EmailHealthCard({ context, actions }) {
const [props, setProps] = useState<Record<string, string> | null>(null);
useEffect(() => {
actions
.fetchCrmObjectProperties(['email', 'email_health_status', 'email_health_score', 'email_suppressed'])
.then(setProps);
}, [context.crm.objectId]);
if (!props) return <Tile><SectionHeader title="Email health" /></Tile>;
return (
<Tile>
<SectionHeader title="Email health" subtitle={props.email} />
<Statistics>
<StatisticsItem label="Deliverability score" number={Number(props.email_health_score ?? 0)} />
</Statistics>
<KeyValueList
items={[
{ label: 'Status', value: props.email_health_status },
{ label: 'Suppressed', value: props.email_suppressed === 'true' ? 'Yes' : 'No' },
]}
/>
</Tile>
);
}
hubspot.extend((props) => <EmailHealthCard {...props} />);Statistics and StatisticsItem are native @hubspot/ui-extensions components for labeled metrics; the two hs-uix imports are the section heading and the dense two-column list you see on every native CRM sidebar.
Modal vs Panel vs IframeModal
HubSpot exposes three overlay patterns, and the right choice is not obvious from the names:
| Use case | Pick | Why |
|---|---|---|
| A form or confirmation rendered in the same iframe | Declarative <Panel> component | Stays in the extension sandbox, fastest to open, no extra bundle |
| A focused workflow that needs the full viewport | Declarative <Modal> component | Larger surface, dims the portal, still in-sandbox |
| Embedding an external URL or a custom HTML app | actions.openIframeModal({ uri, width, height }) | Renders a separate iframe; a result has to come back through your own channel |
<Modal> and <Panel> are declarative components from @hubspot/ui-extensions, but they do not take an open prop. You pass the element as the overlay prop of the Button (or Link, Image, or DropdownButtonItem) that opens it, give it an id, and close it with actions.closeOverlay(id):
import { Button, Modal, ModalBody, Text, hubspot } from '@hubspot/ui-extensions';
function ReverifyButton({ actions }) {
return (
<Button
overlay={
<Modal id="reverify" title="Re-verify this address?">
<ModalBody>
<Text>This calls the verification provider and updates the stored verdict.</Text>
<Button onClick={() => actions.closeOverlay('reverify')}>Cancel</Button>
</ModalBody>
</Modal>
}
>
Re-verify
</Button>
);
}
hubspot.extend(({ actions }) => <ReverifyButton actions={actions} />);Only openIframeModal is an imperative action, because the child surface is a separate cross-origin iframe; it takes an onClose callback but returns nothing about what happened inside, so a child that needs to hand a value back has to do it through your backend (write, then re-fetch on close) rather than through the overlay API.
Hit the performance budget
HubSpot’s iframe is a cold start. The first time a user opens a record, the renderer downloads your bundle, parses it, mounts React, and only then does your component render. On a fast laptop this is under 400 ms; on a salesperson’s office wifi from a coffee shop it can be two full seconds. The numbers are non-negotiable — you can’t make the cold start faster — but you can make the perceived latency much better by rendering a skeleton on first paint, deferring secondary lookups, and lazy-loading anything you don’t strictly need at mount.
Layout first
Render the shape of the final UI immediately, with LoadingSpinner placeholders in the slots that need data. The user sees the layout in 50 ms and the data fills in as it arrives, instead of staring at a blank tile for a second.
function EmailHealthCard({ context, actions }) {
const [stored, setStored] = useState<Record<string, string> | null>(null);
const [verdict, setVerdict] = useState<Verdict | null>(null);
useEffect(() => {
// Cheap: the last verdict validate-email wrote, straight from the record.
actions.fetchCrmObjectProperties(['email', 'email_health_status']).then(setStored);
// Expensive: a fresh verification through the email-health backend.
emailHealth<{ ok: boolean; result: Verdict }>({
input: { contactId: String(context.crm.objectId) },
}).then((res) => {
if (res.ok && res.data.ok) setVerdict(res.data.result);
});
}, [context.crm.objectId]);
return (
<Tile>
<SectionHeader title="Email health" subtitle={stored?.email} />
<KeyValueList items={[{ label: 'Last status', value: stored?.email_health_status }]} />
{verdict ? (
<KeyValueList items={[{ label: 'Fresh score', value: verdict.score }]} />
) : (
<LoadingSpinner />
)}
</Tile>
);
}The last stored verdict lands first (one HubSpot round-trip from inside the portal); the fresh one from the email-health backend fills in when the Worker round-trip lands. Both states are useful, and neither blocks the other.
Deferred lookups
A common mistake is firing every server call in the first useEffect. If your card has a header, a fresh verdict, a verification history, and a suppression check, that’s four card-backend fetches racing the cold start. Stagger them: render the header and last-known status from the property fetch immediately, fire the verdict call on mount, and defer the history and suppression calls to a requestIdleCallback or a hover / scroll trigger. The user sees something useful in under a second, and the secondary data hydrates while they read.
Lazy imports
HubSpot enforces a payload cap on extension bundles; check your platform version’s current limit, since the exact ceiling moves between releases. HubSpot’s remote build reports the bundle size in its build log; hs-x deploy surfaces the per-component verdict but not the byte count. Common culprits and fixes:
- A chart library imported eagerly. Use
React.lazy(() => import('./Chart'))and render the chart behind a<Suspense fallback={<LoadingSpinner />}>. The chart code only downloads when the user actually scrolls to it. - A date library on the critical path.
date-fnsis fine if you import the specific functions you use;momentis not. Inspect the build output (or usewrangler/ standard bundle-analyzer tooling) to confirm what’s actually shipping. - Importing kitchen-sink helpers from
hs-uix. Stick to named imports of just the components you need so tree-shaking inside the HubSpot loader has the best chance of dropping the rest.
Why this matters
The extension that renders in 200 ms feels fast even when it isn’t. The extension that renders in 1,500 ms feels broken even when it returned the right answer. Salespeople open and close record tabs hundreds of times a day; the perceived performance budget is the single biggest factor in whether your extension gets used or quietly ignored.
Deploy and triage validator errors
hs-x deploy runs HS-X’s own validator (the same checks as hs-x check), then uploads and lets HubSpot’s remote build run its checks, plus the Cloudflare push. For UI extensions specifically, the checks are: the generated card metadata matches the card({ … }) declaration and its backend link resolves, the .tsx file registers a component through hubspot.extend, every imported package is on the sandbox allow-list, and the compiled bundle stays under the size budget. The first is HS-X’s; the other three are HubSpot’s remote build, reported per component in the deploy output.
hs-x deploy$ hs-x deploy # hs-x deploy * email-guard [ok] Validating project 1 workers, 3 capabilities * Sources: suppression-list (pull, 5m) * Runtime: Cloudflare Worker required * Cloudflare deploy: hsx-local-865a8a52-email-guard-email-guard * HubSpot build #4: SUCCESS * [ok] email-guard (APPLICATION) * [ok] validate-email (WORKFLOW_ACTION) * [ok] email-health-card (CARD) * Generated .hs-x/manifest.json and refs stubs. Deployed (unlinked) to https://hsx-local-865a8a52-email-guard-email-guard.<account>.workers.dev
Open any contact record. The card is in the right rail, rendering the last stored verdict and fetching a fresh one through the email-health backend. The [ok] email-health-card (CARD) line is HubSpot’s remote build accepting the extension; a failed component prints its build error in the same list.
One fix-line per validator error
The four errors below cover most of what real users hit. Each one has exactly one cause once you know where to look.
No supported components— the.tsxfile didn’t reachhubspot.extend(...). Add the call at the bottom of the file with your component inside.hubspot.fetchrejected before it leaves the iframe — the origin the card is calling isn’t in the app’spermittedUrls.fetchallow-list. The deploy adds the Worker’s own origin automatically; a custom domain or a second origin has to be declared indefineApp, and a changedworkers.devURL needs a redeploy so HubSpot’s metadata catches up.- A CSP violation on
connect-srcfrom the iframe — the extension called barefetchdirectly. Usehubspot.fetch, which proxies through HubSpot and honors the allow-list. Couldn't find the following components: EmailHealthCard— theentrypointincard({ … })doesn’t match the.tsxfilename undersrc/app/cards/. Rename one to match the other.
Common bundle-size errors
- Bundle size exceeded the platform cap — open the HubSpot build log the deploy output links to and look for a per-package breakdown. Usually it’s an eager chart import or a kitchen-sink import from
hs-uix. Move charts behindReact.lazy, switch to named imports of only what you use. Disallowed import: react-hook-form— the package isn’t on the sandbox allow-list and won’t load at runtime. Usehs-uixform components or rawInput+useStatefor forms.Disallowed import: axios— the iframe can’t make arbitrary outbound requests. Make the call from your card backend on the Worker instead.
