view .md
Guides · Build

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.

Time
≈ 15 min
Outcome
Email Guard's email-health card live on contact records, typed end-to-end from your schema to the rendered component, fetching live verdicts from your Worker, with hot reload via dev mode.
Prerequisites
  • The Email Guard project from the Get started guide (hs-x init email-guard --type workflow-action --ui-extension), or any HS-X project.
  • A connected HubSpot developer account (hs-x connect hubspot).
  • @hs-x/sdk on the lockfile — the scaffold adds it (alongside @hs-x/runtime).
  • A coding agent is optional. With the HS-X MCP server registered, it can validate the project and invoke your card backend locally as you build.

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...AllowedUse instead
Make an HTTP request from the iframeNo bare fetch, axios, kyhubspot.fetch to your Worker's card-backend route; the Worker origin must be allow-listed in permittedUrls.fetch
Route between viewsNo react-router, no useNavigateDeclarative <Modal> / <Panel> components or actions.openIframeModal
Build a formNo react-hook-form, no formikhs-uix form components or raw Input / Select with useState
AnimateNo framer-motionhs-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 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.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; do the absolute minimum on mount and defer everything else.
  • 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-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 .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 four 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. When Email Guard later adds email_suppression_reason to the card, the first move is adding it to this array and redeploying.
  • objectTypes constrains which records the card 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: EmailHealthCard." 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/.
  • Card doesn't appear in the portal. Most often the objectTypes entry 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. 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: 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.fetch is rejected before it leaves the iframe. The Worker origin isn't in permittedUrls.fetch. Add it in defineApp and redeploy; HubSpot proxies hubspot.fetch and enforces that allow-list.
  • The Worker answers 404 unknown_card_backend. The id in the URL doesn't match a declared worker.cardBackend id. 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.hubspot resolves 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 (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.
  • <Statistics> / <StatisticsItem> — native @hubspot/ui-extensions components 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 a Tile. 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 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.

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-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
$ 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 .tsx file didn't reach hubspot.extend(...). Add the call at the bottom of the file with your component inside.
  • hubspot.fetch rejected before it leaves the iframe — the Worker origin isn't in your app's permittedUrls.fetch allow-list. Add it in defineApp and redeploy.
  • A CSP violation on connect-src from the iframe — the extension called bare fetch directly. Use hubspot.fetch, which proxies through HubSpot and honors the allow-list.
  • Couldn't find the following components: EmailHealthCard 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. 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 reads context.crm, calls a worker.cardBackend through the generated _hsx-backend.ts client, 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 (the hs-x dev proxy's HMAC over the x-hsx-* headers). Regenerate the secret-free client with bun run codegen, exercise both paths with bun 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.crm shape 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.