view .md
How to · Build

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.

Time
≈ 15 min
Outcome
A polished UI extension running on a HubSpot record (or app home / settings / modal) surface, typed end-to-end from your schema to the rendered component, with hot reload via dev mode.
Prerequisites
  • An HS-X project initialized via hs-x init (see the Get started guide).
  • A connected HubSpot dev portal (hs-x login hubspot --portal dev).
  • @hs-x/sdk and @hs-uix on the lockfile — the scaffold adds both.
  • A coding agent is optional. The HS-X MCP server can drive every step here from chat.

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...AllowedUse instead
Make an HTTP request from the iframeNo fetch, axios, kyrunServerlessFunction to your Worker (server-side hubspot.fetch runs only inside the function, never the iframe)
Route between viewsNo react-router, no useNavigateDeclarative <Modal> / <Panel> components or actions.openIframeModal
Build a formNo react-hook-form, no formik@hs-uix form components or raw Input / Select with useState
AnimateNo framer-motion@hs-uix transition components or LoadingSpinner
Access the DOMNo document, no refs to native nodesCompose with @hs-uix / @hubspot/ui-extensions primitives only
Read CRM dataYes, via context.crmFirst-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.crm has the record id, object type, and the properties you list in meta.json. Best for rich layouts that need horizontal room.
  • crm.record.sidebar — a narrow card in the right-rail of a record. Same context.crm as 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. context exposes 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 .tsx file is the runtime. hubspot.extend(fn) is the registration call — HubSpot's renderer invokes fn with the context object once the iframe mounts, and renders whatever React tree you return. There is exactly one hubspot.extend call per file; multiple calls register the last one only.
  • The *-hsmeta.json is the build-time contract. The location string maps to one of the five surfaces. The properties array is the load-bearing field: HubSpot pre-fetches those CRM fields server-side and threads them into context.crm before your component mounts. Properties you don't list are not on context and you can't read them, full stop. Add the property to the array, redeploy, and it shows up.
  • objectTypes constrains which records the tab appears on. Omit it on settings and home. 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 }) => ...), not hubspot.extend((c) => ...). The CLI parses the call shape, not the runtime behavior.
  • "Couldn't find the following components: AccountTab." The file field in *-hsmeta.json doesn't match the .tsx filename, or the .tsx lives in a subfolder. Keep all extension files flat under extensions/.
  • Tab doesn't appear in the portal. Most often the objectTypes entry 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. Run hs-x doctor to 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 fetch from 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 through hubspot.* and don't count).
  • A CSP violation on connect-src appears in the browser console. The iframe's content security policy blocks direct outbound requests from the extension. Route the call through runServerlessFunction to your Worker instead.
  • Function call returns undefined silently. The matching worker.fn name 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 (so amount formats as currency, closedate as 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 a Tile. 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 casePickWhy
A form or confirmation rendered in the same iframeDeclarative <Panel> componentStays in the extension sandbox, fastest to open, no extra bundle
A focused workflow that needs the full viewportDeclarative <Modal> componentLarger surface, dims the portal, still in-sandbox
Embedding an external URL or a custom HTML appactions.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-fns is fine if you import the specific functions you use; moment is not. Inspect the build output (or use wrangler / 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
Expect
✓ 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 .tsx file didn't reach hubspot.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 through hubspot.* and don't count).
  • A CSP violation on connect-src from the iframe — the extension itself called fetch directly. Route the call through runServerlessFunction to your Worker instead.
  • Couldn't find the following components: AccountTab the file field in *-hsmeta.json doesn't match the .tsx filename. Rename one to match the other, or move the file flat into extensions/.

Common bundle-size errors

  • Bundle size exceeded the platform cap — run hs-x doctor and 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 behind React.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-uix form components or raw Input + useState for forms.
  • Disallowed import: axios the iframe can't make arbitrary outbound requests. Use runServerlessFunction to 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.crm shape you ship in prod.
  • How to · Workflow actions — expose the same worker.fn you called from runServerlessFunction as 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.