UI extensions: components that render inside HubSpot.
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 walks through declaring one with HS-X and @hs-uix, reading typed CRM context, calling a server function on your Worker, and shipping a build that survives HubSpot's sandbox rules and bundle budget.
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 fetch, axios, ky | runServerlessFunction to your Worker (server-side hubspot.fetch runs only inside the function, never the iframe) |
| 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 five 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; budget under 50 ms of work on mount.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 home / settings (
settings,home) — full-page surfaces inside your installed app. No record context; you get a portal id and the installer's user id, and that's it.
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 both for a contact sidebar. To add a record tab, copy the pair and change the location field.
// extensions/AccountTab.tsx
import { Tile, hubspot } from '@hubspot/ui-extensions';
import { SectionHeader } from '@hs-uix';
function AccountTab({ context }) {
return (
<Tile>
<SectionHeader title="Account health" />
</Tile>
);
}
hubspot.extend(({ context }) => <AccountTab context={context} />);// extensions/account-tab-hsmeta.json
{
"type": "ui-extension",
"location": "crm.record.tab",
"objectTypes": ["COMPANY"],
"title": "Account health",
"uid": "account-tab",
"file": "AccountTab.tsx",
"properties": ["name", "domain", "annualrevenue", "hubspot_owner_id"]
}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 five 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. Add the property to the array, redeploy, and it shows up. objectTypesconstrains which records the tab 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: AccountTab." The
filefield in*-hsmeta.jsondoesn't match the.tsxfilename, or the.tsxlives in a subfolder. Keep all extension files flat underextensions/. - Tab doesn't appear in the portal. Most often the
objectTypesentry is wrong (HubSpot expects uppercase constants like"COMPANY", not"company"or"companies") 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: there's no useCrm() hook, no subscription, no live binding. If a user edits a property in another tab while your extension is open, context.crm does not update. To force a refresh after an in-portal save, call actions.refreshObjectProperties() and re-read.
import { Tile, Text, hubspot } from '@hubspot/ui-extensions';
import { KeyValueList, SectionHeader, formatCurrency } from '@hs-uix';
function AccountTab({ context, actions }) {
return (
<Tile>
<SectionHeader title={context.crm.name ?? 'Account'} />
<KeyValueList
items={[
{ label: 'Domain', value: context.crm.domain },
{ label: 'ARR', value: formatCurrency(context.crm.annualrevenue) },
{ label: 'Owner', value: context.crm.hubspot_owner_id },
]}
/>
</Tile>
);
}
hubspot.extend(({ context, actions }) => <AccountTab context={context} actions={actions} />);Why context.crm is typed against your schema
@hs-uix reads schema/*.ts 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 (enum unions, currency as number, owner as OwnerId). If you rename annualrevenue to arr in your 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
If you need an associated object (the deals on a company, the engagements on a contact, the line items on a deal), context.crm won't carry them. Use a server function for those — covered in the next step. The same applies to anything that isn't a CRM property: custom queries, third-party API calls, computed aggregations.
Call a server function with runServerlessFunction
runServerlessFunction is the typed RPC bridge between your iframe and your Worker. You call it by name from the extension, the Worker runs the matching worker.fn declaration with the portal's auth context, and the typed return value lands back in the iframe. The function runs in your Cloudflare account, with full HubSpot API access via the portal token, and is the right place to put anything that needs network IO, secret-bearing requests, or aggregation across many records.
// worker.ts
import { defineWorker } from '@hs-x/sdk';
export default defineWorker(({ worker }) => {
worker.fn('listOpenDeals', {
input: { companyId: 'string' },
async run({ input, hubspot }) {
// Illustrative: use `@hubspot/api-client`'s search or associations APIs
// here, e.g. hubspot.crm.deals.searchApi.doSearch({...}) filtered to
// deals associated with input.companyId.
const deals = await hubspot.crm.deals.searchApi.doSearch({
filterGroups: [
{
filters: [
{ propertyName: 'associations.company', operator: 'EQ', value: input.companyId },
{ propertyName: 'dealstage', operator: 'NOT_IN', values: ['closedwon', 'closedlost'] },
],
},
],
properties: ['dealname', 'amount', 'closedate'],
});
return { deals: deals.results };
},
});
});// extensions/AccountTab.tsx
import { useEffect, useState } from 'react';
import { hubspot, Tile, LoadingSpinner } from '@hubspot/ui-extensions';
import { DataTable } from '@hs-uix';
function OpenDeals({ runServerlessFunction, companyId }) {
const [deals, setDeals] = useState(null);
useEffect(() => {
runServerlessFunction({ name: 'listOpenDeals', parameters: { companyId } }).then((res) => {
setDeals(res.response.deals);
});
}, [companyId, runServerlessFunction]);
if (!deals) return <LoadingSpinner label="Loading deals" />;
return <DataTable rows={deals} columns={['dealname', 'amount', 'closedate']} />;
}
hubspot.extend(({ context, runServerlessFunction }) => (
<Tile>
<OpenDeals runServerlessFunction={runServerlessFunction} companyId={context.crm.objectId} />
</Tile>
));When to use runServerlessFunction vs 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), use runServerlessFunction. The cost of a server function 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 runServerlessFunction issues
- Outbound
fetchfrom a function fails. The serverless function isn't configured to allow outbound network calls — declare the capability in the function's meta, or call HubSpot's APIs (which go throughhubspot.*and don't count). - A CSP violation on
connect-srcappears in the browser console. The iframe's content security policy blocks direct outbound requests from the extension. Route the call throughrunServerlessFunctionto your Worker instead. - Function call returns
undefinedsilently. The matchingworker.fnname doesn't exist. The CLI flags this at build time only if the call site is a string literal. If you're computing the name, you're on your own.
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.<Stat label value delta icon />— a single labelled metric with optional trend. The card 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, Stat, DataTable, KeyValueList } from '@hs-uix';
import { Tile, hubspot } from '@hubspot/ui-extensions';
function AccountTab({ context, runServerlessFunction }) {
return (
<Tile>
<SectionHeader title="Account health" subtitle={context.crm.domain} />
<Stat label="ARR" value={context.crm.annualrevenue} delta="+12%" />
<KeyValueList
items={[
{ label: 'Owner', value: context.crm.hubspot_owner_id },
{ label: 'Lifecycle', value: context.crm.lifecyclestage },
]}
/>
</Tile>
);
}
hubspot.extend((props) => <AccountTab {...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.
Skeleton first
Render the shape of the final UI immediately, with LoadingSpinner or @hs-uix's <Skeleton /> 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 AccountTab({ context, runServerlessFunction }) {
const [deals, setDeals] = useState(null);
useEffect(() => {
runServerlessFunction({ name: 'listOpenDeals', parameters: { companyId: context.crm.objectId } })
.then((res) => setDeals(res.response.deals));
}, [context.crm.objectId]);
return (
<Tile>
<SectionHeader title={context.crm.name} />
<KeyValueList items={[{ label: 'ARR', value: context.crm.annualrevenue }]} />
{deals ? <DataTable rows={deals} columns={['dealname', 'amount']} /> : <Skeleton lines={3} />}
</Tile>
);
}Deferred lookups
A common mistake is firing every server call in the first useEffect. If your tab has a header, a deals list, an activity feed, and a notes section, that's four runServerlessFunction calls racing the cold start. Stagger them: render the header from context.crm immediately, fire the deals call on mount, and defer the activity and notes 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={<Skeleton />}>. 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✓ bundle 0.9s extension 142 kb · worker 86 kb ✓ validate 0.4s 1 extension · 0 conflicts ✓ portal 1.8s v23 · account-tab registered on crm.record.tab ✓ worker 1.4s iad → my-portal.workers.dev → open https://app.hubspot.com/portal/<id>/companies/<id>
Open any company record. The new tab is on the secondary nav, rendering whatever your extension returned.
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.- Outbound request blocked from a
worker.fn— the serverless function isn't configured to allow outbound network calls. Declare the capability in the function's meta, or call HubSpot's APIs (which go throughhubspot.*and don't count). - A CSP violation on
connect-srcfrom the iframe — the extension itself calledfetchdirectly. Route the call throughrunServerlessFunctionto your Worker instead. Couldn't find the following components: AccountTab— 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 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. Use@hs-uixform components or rawInput+useStatefor forms.Disallowed import: axios— the iframe can't make arbitrary outbound requests. UserunServerlessFunctionto put the call on your Worker.
Where next
- 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 — expose the same
worker.fnyou called fromrunServerlessFunctionas a workflow action so HubSpot workflows (and Breeze agents) can invoke it. - How to · Marketplace listing — turn the extension into a public app card, with the install flow, the OAuth scopes, and the screenshots the marketplace review wants.