This post guides you through creating a comprehensive App Home and Settings experience for a HubSpot integration using Gadget. Building on the previous custom object template, it unveils HubSpot's new App Home Page, offering a centralized view of your custom object.
Building the HubSpot App Home and Settings Page on Gadget
HubSpot's much-awaited and overdue feature: The App Home Page! (And also, a Settings page).
When creating ad hoc HubSpot apps for internal use, one thing never felt right to me was the lack of a centralized overview page for the integration. Having only an app card to view data feels restrictive. If you wanted to shoehorn in a method to have an overview of all data handled by the app with just the app card, you would need to create a clunky solution or compromise the clarity of the app card.
That's why, for this app template, we built up from the previous HubSpot Private app template as it's a great use case for having an app homepage to display all of the teams in an organization, grouped by company.
Where we left off
In the last post, we built a HubSpot app card on Gadget that lets users group contacts from a company into named teams that were then stored as a custom object (customObjectTeam) in Gadget. The app card is scoped to a single company record: you pick contacts, name the team, and save.
That works well for creation. But it leaves an obvious gap: there's nowhere to get a bird's-eye view of every team across the entire portal, no easy way to clean up stale data, and no easy way to organize any WebUI modification tools inside HubSpot. That's what this post is about.
The App Home Page
HubSpot's App Home Page (currently in public beta, sign up at https://app.hubspot.com/l/product-updates/new-to-you?rollout=237984) gives your app a proper landing page, which you can quickly access from the Marketplace icon under Your recently visited apps, or directly at https://app.hubspot.com/app/{HubID}/{appId}.
The app Home Page is built identically to a card extension, a React component using hubspot/ui-extensions, registered with hubspot.extend<"home"> and configured via an *-hsmeta.json file with "location": "home". HubSpot recommends using certain hubspot ui-extension components and props to fit the UI expectations of a Home page, same idea with the Settings page.
What it shows
The homepage gives a full organizational view: every company, every team under it, and every contact in each team, all in one scrollable page.
Company logos are pulled from HubSpot and displayed inline. Every company name and contact name is a deep link directly to their HubSpot record so this page doubles as a quick-navigation tool.
The data architecture
The key design choice here is that Gadget only stores IDs. The customObjectTeam model holds teamName, portalId, a parentCompany number, and teamContacts (a JSON array of HubSpot contact IDs). No personal identifiers, no logo image files.
When the homepage loads, the GET /hubspot/all-teams route:
Queries Gadget for all
customObjectTeamrecords belonging to the portal.Collects all unique parentCompany IDs and fires a single batch request to the HubSpot CRM companies API for names and logos.
Collects all unique contact IDs across every team and fires a single batch request to the HubSpot CRM contacts API for names, titles, and emails.
Assembles and returns a response grouped by company, with ID arrays replaced by fully hydrated objects.
// Fetch all teams for this portal
const teams = await api.customObjectTeam.findMany({
filter: { portalId: { equals: portalIdNumber } },
select: {
id: true,
teamName: true,
teamContacts: true,
parentCompany: true,
portalId: true,
},
});
// Get unique company IDs
const uniqueCompanyIds = [
...new Set(
teams
.map((t) => t.parentCompany)
.filter((id): id is number => id != null)
.map(String)
),
];
// Batch-fetch company names and logos from HubSpot
const companyNames: Record<string, string> = {};
const companyLogos: Record<string, string> = {};
if (uniqueCompanyIds.length > 0) {
try {
const batchResponse = await hubspotClient.crm.companies.batchApi.read({
inputs: uniqueCompanyIds.map((id) => ({ id })),
properties: ["name", "hs_logo_url"],
propertiesWithHistory: [],
});
for (const company of batchResponse.results) {
companyNames[company.id] =
company.properties.name || `Company ${company.id}`;
if (company.properties.hs_logo_url) {
companyLogos[company.id] = company.properties.hs_logo_url;
}
}
} catch (err) {
logger.warn({ err }, "Failed to fetch company names from HubSpot; using IDs as fallback");
}
}
// Collect all unique contact IDs across all teams
const uniqueContactIds = [
...new Set(
teams.flatMap((t) =>
Array.isArray(t.teamContacts)
? (t.teamContacts as (string | number)[]).map(String)
: []
)
),
];
Two API calls to HubSpot, regardless of how many companies or teams exist. This same route is reused by both the Homepage and the Settings page.
Auth
Like the App card, the Homepage authenticates by first calling POST /hubspot/auth to get a short-lived JWT, then passing it as a Bearer token on subsequent requests. The token is validated server-side by requireHubspotJwt, which checks the signature, confirms the session isn't expired, and extracts the portalId to scope all database queries.
The Settings Page
The settings page lives at Marketplace → My Apps → [your app] → Settings and is configured with "type": "settings" in its *-hsmeta.json. It shares the same React + hubspot/ui-extensions toolkit as the homepage and card, with one important constraint: no hubspot/ui-extensions/crm components, since settings isn't tied to any CRM object.
Where the homepage is read-only, the settings page is where you manage your teams. It loads the same GET /hubspot/all-teams data, but adds two actions per team:
Removing contacts
Clicking Manage Contacts on a team opens a modal panel. Inside is a checkbox list of every contact on that team. Selecting contacts and hitting Remove
Selected calls POST /hubspot/remove-contacts, which:
- Verifies the team belongs to the current portal (no cross-portal writes).
- Filters the teamContacts array to exclude the selected IDs.
- Saves the updated array back to the Gadget record.

The UI updates immediately in state, no reload needed.
Deleting teams
Delete Team uses a two-step confirm pattern: the first click swaps the button for Confirm Delete and Cancel, requiring a second deliberate action before anything is actually deleted. This calls POST /hubspot/delete-team, which performs the same portal ownership check before calling api.customObjectTeam.delete.
Deleted teams disappear from the list immediately. Companies with no remaining teams are removed too.
What this unlocks
Adding the homepage and settings page to the custom objects template completes a proper app loop:
- Create teams from the company app card
- View all teams org-wide on the app homepage
- Manage (remove contacts, delete teams) from the settings page
The separation of concerns is clean: the card is scoped to one company record, the homepage is a read-only portal-wide view, and the settings page is where administrative actions live. None of them needed new models, just new routes and UI extensions on top of the same customObjectTeam model and GET-all-teams endpoint.



Top comments (0)