UI extensions
A HubSpot UI extension is a React component HubSpot renders for you inside a sandboxed iframe on a CRM record, a help-desk ticket, or an app home surface. This guide builds Email Guard's email-health card end to end: declaring the surface, reading typed CRM context, fetching a live deliverability verdict from the email-health card backend on your Worker, 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, help desk, settings, or app home. With HS-X and hs-uix you declare it next to your worker, read typed CRM context, fetch from your Worker's card backends with hubspot.fetch, and hot-reload the whole loop with hs-x dev. 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 builds your extension with the same loader HubSpot's CI uses, so a bundle that builds locally will register cleanly on deploy. You'll still hit the sandbox rules occasionally, but you'll hit them at compile time 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; the Worker origin must be allow-listed in permittedUrls.fetch |
| Route between views | No react-router, no useNavigate | Declarative <Modal> / <Panel> components 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 | Yes, via context.crm | First-class, typed against your schema |
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, object type, and the properties you list inmeta.json. 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 a string you put in the extension's *-hsmeta.json. Changing it is a single-file 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 two files. A .tsx file under extensions/ that exports a React component via hubspot.extend(...), and a sibling *-hsmeta.json that tells HubSpot which surface to render on and which CRM properties to hydrate into context. The scaffold from hs-x init already has a starter pair. 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.
// extensions/EmailHealthCard.tsx
import { Tile, hubspot } from '@hubspot/ui-extensions';
import { SectionHeader } from 'hs-uix/common-components';
function EmailHealthCard({ context }) {
return (
<Tile>
<SectionHeader title="Email health" />
</Tile>
);
}
hubspot.extend(({ context }) => <EmailHealthCard context={context} />);One install step before this compiles: hs-uix is a card-side dependency, so it lives in two places. bun add -D hs-uix covers local type-checking, and adding "hs-uix": "^2.2.0" to the card-side package.json (src/app/cards/package.json in the scaffold) is what lets HubSpot's remote build resolve the import — the remote build installs the card's dependencies from that file, not from your project root. hs-uix/common-components is the subpath that ships SectionHeader, KeyValueList, and the other shared primitives; the heavier components have their own subpaths (hs-uix/datatable, hs-uix/form).
// extensions/email-health-card-hsmeta.json
{
"type": "ui-extension",
"location": "crm.record.sidebar",
"objectTypes": ["CONTACT"],
"title": "Email health",
"uid": "email-health-card",
"file": "EmailHealthCard.tsx",
"properties": ["email", "email_health_status", "email_health_score", "email_suppressed"]
}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
*-hsmeta.jsonis the build-time contract. Thelocationstring maps to one of the four surfaces. Thepropertiesarray is the load-bearing field: HubSpot pre-fetches those CRM fields server-side and threads them intocontext.crmbefore your component mounts. Properties you don't list are not oncontextand you can't read them, full stop. When Email Guard later addsemail_suppression_reasonto the card, the first move is adding it to this array and redeploying. objectTypesconstrains which records the card appears on. Omit it onsettingsandhome. Use uppercase HubSpot constants ("CONTACT","COMPANY","DEAL","TICKET") or numeric ids like"0-1"for custom objects.
Common declaration issues
- "No supported components" on deploy. The validator couldn't find a default export wired through
hubspot.extend. The function arg name has to be destructured, not implicit —hubspot.extend(({ context }) => ...), nothubspot.extend((c) => ...). The CLI parses the call shape, not the runtime behavior. - "Couldn't find the following components: EmailHealthCard." The
filefield in*-hsmeta.jsondoesn't match the.tsxfilename, or the.tsxlives in a subfolder. Keep all extension files flat underextensions/. - Card doesn't appear in the portal. Most often the
objectTypesentry is wrong (HubSpot expects uppercase constants like"CONTACT", not"contact"or"contacts") or the user account doesn't have the developer-projects beta enabled on that record type. Runhs-x doctorto check the portal-side flags.
Read CRM context with context.crm
The context object HubSpot hands to your extension is the entire data API for the iframe. context.crm carries the record id, object type, and the properties you listed in *-hsmeta.json. It is populated once, at mount, from the server-side prefetch. That single sentence resolves most of the confusion: context.crm itself has no hook, no subscription, no live binding. If the validate-email workflow action writes a fresh verdict while the record is open in another tab, context.crm does not update. To force a refresh after an in-portal save, call actions.refreshObjectProperties() and re-read. The one live-data exception lives outside context: @hubspot/ui-extensions/crm ships useAssociations for association data.
import { Tile, hubspot } from '@hubspot/ui-extensions';
import { KeyValueList, SectionHeader } from 'hs-uix/common-components';
function EmailHealthCard({ context, actions }) {
return (
<Tile>
<SectionHeader title={context.crm.email ?? 'No email on record'} />
<KeyValueList
items={[
{ label: 'Status', value: context.crm.email_health_status },
{ label: 'Score', value: context.crm.email_health_score },
{ label: 'Suppressed', value: context.crm.email_suppressed ? 'Yes' : 'No' },
]}
/>
</Tile>
);
}
hubspot.extend(({ context, actions }) => <EmailHealthCard context={context} actions={actions} />);Why context.crm is typed against your schema
hs-uix reads your schema at build time and emits a .d.ts that narrows context.crm to exactly the fields you listed in *-hsmeta.json, with the types from your schema. email_health_status reads as the union 'deliverable' | 'risky' | 'undeliverable', not string. If you rename email_health_score to score in the schema, the extension stops type-checking until you update the read. The single source of truth is the schema file, not the JSON meta.
The JSON meta still has to list the property name, because HubSpot's prefetch needs it server-side, but the type of the value comes from the schema. Keep the two in sync and you never have to write a manual as cast.
When context.crm is not enough
context.crm carries the last stored verdict, which is exactly what a sidebar card should render first. What it can't 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 — covered in 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 with 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 the Worker's origin in your app's permittedUrls.fetch allow-list.
// worker.ts
import { defineWorker } from '@hs-x/sdk';
const worker = defineWorker('email-guard');
worker.cardBackend('email-health', {
label: 'Email health',
async handler({ input, env, hubspot }) {
const contactId = String((input as { contactId?: unknown }).contactId ?? '');
const contact = await hubspot.crm.objects.contacts.get(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 — allow-list the Worker origin for hubspot.fetch
export default defineApp({
// ...name, distribution, auth, scopes...
permittedUrls: {
fetch: ['https://email-guard.yourteam.workers.dev'],
},
});// src/app/cards/EmailHealthCard.tsx
import { useEffect, useState } from 'react';
import { hubspot, Tile, LoadingSpinner } from '@hubspot/ui-extensions';
const WORKER = 'https://email-guard.yourteam.workers.dev';
function EmailHealth({ contactId }) {
const [verdict, setVerdict] = useState(null);
useEffect(() => {
hubspot
.fetch(`${WORKER}/_hsx/cards/email-health`, {
method: 'POST',
body: { contactId },
})
.then((res) => res.json())
.then((data) => setVerdict(data.result));
}, [contactId]);
if (!verdict) return <LoadingSpinner label="Checking deliverability" />;
return <Tile>{/* render status, score, checkedAt */}</Tile>;
}
hubspot.extend(({ context }) => <EmailHealth contactId={context.crm.objectId} />);The response envelope is { ok, capabilityId, result }, where result is whatever your handler returned. Codegen also emits a typed reference for every card backend (its id plus the dispatch method and path), so extension code can import the route instead of hand-writing the string.
When to fetch vs when to read context.crm
A simple rule: if the field is on the record itself and you can list it in *-hsmeta.json, read it from context.crm. The prefetch is free, it lands before mount, and it never costs a network round-trip from the iframe. 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 (typically under 100 ms on Cloudflare's edge), which is fast but not free.
Common card-backend issues
hubspot.fetchis rejected before it leaves the iframe. The Worker origin isn't inpermittedUrls.fetch. Add it indefineAppand redeploy; HubSpot proxieshubspot.fetchand enforces that allow-list.- 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; the typed refs exist so this class of typo dies at compile time. - 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. 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 sortable table, a labelled metric, or a section header from scratch every time is the kind of repetition hs-uix exists to remove.
The four components below cover ~80% of what real extensions render:
<KeyValueList items={[{label, value}]} />— the dense, two-column property list you see on every native CRM sidebar. Handles missing values, formats currencies / owners / dates from your schema, lays out under 320 px without overflow.<DataTable rows columns sort filter pageSize />— sortable, filterable, virtualized table. Reads column type from your schema (soamountformats as currency,closedateas a relative date) and persists sort state to URL params so a refresh doesn't lose your view.<Statistics>/<StatisticsItem>— native@hubspot/ui-extensionscomponents for labelled metrics with optional trend. The block you use to surface a KPI at the top of a tab.<SectionHeader title subtitle actions />— the heading row that separates sections inside aTile. Matches HubSpot's spacing and lets you slot a<Button>into the right side without manual flex math.
import { SectionHeader, KeyValueList } from 'hs-uix/common-components';
import { Tile, Statistics, StatisticsItem, hubspot } from '@hubspot/ui-extensions';
function EmailHealthCard({ context }) {
return (
<Tile>
<SectionHeader title="Email health" subtitle={context.crm.email} />
<Statistics>
<StatisticsItem label="Deliverability score" number={context.crm.email_health_score} />
</Statistics>
<KeyValueList
items={[
{ label: 'Status', value: context.crm.email_health_status },
{ label: 'Suppressed', value: context.crm.email_suppressed ? 'Yes' : 'No' },
]}
/>
</Tile>
);
}
hubspot.extend((props) => <EmailHealthCard {...props} />);Modal vs Panel vs IframeModal
The other place hs-uix saves you a half-day of reading docs is overlays. HubSpot exposes three 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 }) | Renders a separate iframe; you'll need the postMessage pattern to receive a result |
<Modal> and <Panel> are React components from @hubspot/ui-extensions — render them inline and toggle their open prop. Only openIframeModal is an imperative action, because the child surface is a separate cross-origin iframe. The postMessage callback pattern is the one wrinkle there: openIframeModal doesn't return a promise of "what the user did inside the iframe" — the child iframe has to call window.parent.postMessage({ type: 'extension-return', payload }) and you have to listen for it. hs-uix wraps this so you write the listener once and never touch raw addEventListener('message', ...) again.
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 }) {
const [verdict, setVerdict] = useState(null);
useEffect(() => {
hubspot
.fetch(`${WORKER}/_hsx/cards/email-health`, {
method: 'POST',
body: { contactId: context.crm.objectId },
})
.then((res) => res.json())
.then((data) => setVerdict(data.result));
}, [context.crm.objectId]);
return (
<Tile>
<SectionHeader title="Email health" subtitle={context.crm.email} />
<KeyValueList items={[{ label: 'Last status', value: context.crm.email_health_status }]} />
{verdict ? (
<KeyValueList items={[{ label: 'Fresh score', value: verdict.score }]} />
) : (
<LoadingSpinner />
)}
</Tile>
);
}The last stored verdict renders instantly from context.crm; the fresh one from the email-health backend fills in when the 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 context.crm 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. The CLI prints the size on every build. 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 1500 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 the same validator HubSpot's CI runs, plus the bundle step and the Cloudflare push. For UI extensions specifically, the validator checks four things: the *-hsmeta.json is well-formed, the .tsx file exports a registered component, every imported package is on the sandbox allow-list, and the compiled bundle stays under the size budget.
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 Worker origin isn't in your app'spermittedUrls.fetchallow-list. Add it indefineAppand redeploy.- 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— thefilefield in*-hsmeta.jsondoesn't match the.tsxfilename. Rename one to match the other, or move the file flat intoextensions/.
Common bundle-size errors
- Bundle size exceeded the platform cap — run
hs-x doctorand inspect the build output for a per-package breakdown. Eight times out of ten it's an eager chart import or a kitchen-sink import fromhs-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.
Where next
- Runnable example:
examples/card-with-backend— the smallest end-to-end card-with-backend in the repo. A "Deal health" card whose React component readscontext.crm, calls aworker.cardBackendthrough the generated_hsx-backend.tsclient, and renders loading / error / result states. It ships tests for both authenticated dispatch paths: the signed production path (HubSpot v3 signature, bound to an active install) and the local dev adapter (thehs-x devproxy's HMAC over thex-hsx-*headers). Regenerate the secret-free client withbun run codegen, exercise both paths withbun test. - How to · Dev mode — wire up the WebSocket tunnel so saves in your editor hot-reload the extension in the open portal in under a second, with the same
context.crmshape you ship in prod. - How to · Workflow actions — the validate-email action that writes the stored verdict this card renders, taken apart field by field.
- How to · Marketplace listing — turn Email Guard into a public app card, with the install flow, the OAuth scopes, and the screenshots the marketplace review wants.
