import { GuideHero, GuideStep, GuideOut } from '~/components/Guide';
import { GuideArticleSchema } from '~/components/Guide/GuideArticleSchema';
import { CopyAsMarkdown } from '~/components/CopyAsMarkdown/CopyAsMarkdown';
import { Figure } from '@hs-x/design';

export const metadata = {
  title: 'How do I build a HubSpot UI extension with HS-X and @hs-uix?',
  description: 'A typed, hot-reloaded HubSpot UI extension end-to-end: pick a surface, read CRM context, call a server function, ship under the sandbox budget.',
  alternates: { canonical: '/docs/guides/ui-extensions' },
  openGraph: {
    type: 'article',
    title: 'How do I build a HubSpot UI extension with HS-X and @hs-uix?',
    description: 'A typed, hot-reloaded HubSpot UI extension end-to-end: pick a surface, read CRM context, call a server function, ship under the sandbox budget.',
    url: '/docs/guides/ui-extensions',
  },
};

<GuideArticleSchema
  url="/docs/guides/ui-extensions"
  title="How do I build a HubSpot UI extension with HS-X and @hs-uix?"
  description="A typed, hot-reloaded HubSpot UI extension end-to-end: pick a surface, read CRM context, call a server function, ship under the sandbox budget."
  datePublished="2026-05-20"
  keywords={['HubSpot UI extensions', '@hs-uix', 'crm.record.tab', 'runServerlessFunction', 'HubSpot iframe sandbox', 'HS-X']}
  howTo={{
    totalTime: 'PT15M',
    steps: [
      { name: 'Declare an extension and pick a surface' },
      { name: 'Read CRM context with context.crm' },
      { name: 'Call a server function with runServerlessFunction' },
      { name: 'Compose with higher-level @hs-uix components' },
      { name: 'Hit the performance budget' },
      { name: 'Deploy and triage validator errors' },
    ],
  }}
/>

<CopyAsMarkdown src="/docs/guides/ui-extensions.md" />

<GuideHero
  eyebrow="How to · Build"
  title="UI extensions: components that render inside HubSpot."
  tagline="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."
  duration="≈ 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.',
  ]}
/>

<GuideStep n={0} title="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.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.

</GuideStep>

<GuideStep n={1} total={6} title="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.

```tsx
// 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} />);
```

```json
// 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.

</GuideStep>

<GuideStep n={2} total={6} title="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.

```tsx
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.

</GuideStep>

<GuideStep n={3} total={6} title="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.

```ts
// 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 };
    },
  });
});
```

```tsx
// 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.

</GuideStep>

<GuideStep n={4} total={6} title="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.

```tsx
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.

</GuideStep>

<GuideStep n={5} total={6} title="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.

```tsx
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.

</GuideStep>

<GuideStep n={6} total={6} title="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.

```sh
hs-x deploy
```

<GuideOut terminal={`✓ bundle      0.9s   extension 142 kb · worker 86 kb\n✓ validate    0.4s   1 extension · 0 conflicts\n✓ portal      1.8s   v23 · account-tab registered on crm.record.tab\n✓ worker      1.4s   iad → my-portal.workers.dev\n\n→ 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.
</GuideOut>

#### 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](/docs/guides/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](/docs/guides/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](/docs/guides/marketplace-listing)** — turn the extension into a public app card, with the install flow, the OAuth scopes, and the screenshots the marketplace review wants.

</GuideStep>
