First Class Modules
Favorites
Gives each user a secured cross-module list of saved records with search, module filters, pinning, change tracking, related context, activity, and dynamic fields.
module.favoritesUse Favorites as a personal shortcut layer so each user can return to important records across the app without changing the records themselves.
The bundled renderer key is module.favorites. First-class means BuildWithHQ supplies a native, typed, secured runtime experience inside normal app provisioning and the signed-in user's existing permissions.
What it does
Gives each user a secured cross-module list of saved records with search, module filters, pinning, change tracking, related context, activity, and dynamic fields.
Key capabilities
- Search personal favorites and filter by source module.
- Pin, reorder, remove, and optionally track changes on saved records.
- Show current secured status, location, relations, activity, and dynamic fields.
- Collapse duplicate favorite targets into a coherent personal workspace.
Common uses
- Pinned customers, projects, cases, files, or articles.
- A personal watch list for records whose changes matter.
- Cross-module navigation for frequently used work.
How it connects
Any supported canonical Record can participate. The favorite stores personal preference; the target remains owned by its native module and is reauthorized whenever it is listed or opened.
Where applicable, its records use the universal RecordId conventions so they can participate in secured relationships, activity history, favorites, dynamic fields, notifications, Inbox attention, and global search without copying the source record.
Security and data boundary
Only the current user's favorite rows are returned. Target records, related records, fields, activity, and location are independently secured, so a favorite never preserves access after permission is removed.
- The authenticated service derives the SaaS app, tenant account, user, DataRole, and location scope; browser identifiers are never authorization proof.
- The page editor composes React components with validated data bindings. Those bindings call typed runtime APIs, whose application services execute reviewed stored procedures.
- List, search, detail, relation, activity, favorite, and write operations reapply their required server-side permissions.
Add it to an app
- Open the SaaS app in the Developer Console and identify the user journey and page where this module belongs.
- Add the validated
module.favoritesmodule block through the supported page/template authoring flow. - Configure the module with the page editor's React components and validated data bindings; the bindings call authenticated platform APIs backed by reviewed stored procedures.
- Place the page in the correct user-type menus and assign existing DataRole, record, field, and location permissions.
- Test list, detail, search, empty, denied, stale-update, and cross-location behavior before publishing an exact version.
Copy/paste the complete Favorites workspace
The native block provides personal search, module and tracked filters, detail, related context, pinning, ordering, change tracking, dynamic fields, and removal.
{
"blocks": [
{ "_id": "my-favorites", "_type": "module.favorites", "props": {}, "children": [] }
]
}
Custom unstyled React list and detail
Each result page is assembled by the secured procedure. Do not cache a favorite as proof that its target is still visible; opening and listing reauthorize the target every time.
import { FormEvent, useEffect, useState } from "react";
import { favoriteDynamicValue, type FavoriteDetail, type FavoritesList } from "@buildwithhq/module-sdk";
import { getFavorite, listFavorites } from "./api";
const empty: FavoritesList = { contractVersion: 1, pageNumber: 1, pageSize: 50, totalRecords: 0, totalPages: 0, modules: [], items: [] };
export function UnstyledFavorites({ locationId = "" }) {
const [draft, setDraft] = useState("");
const [search, setSearch] = useState("");
const [moduleKey, setModuleKey] = useState("");
const [trackedOnly, setTrackedOnly] = useState(false);
const [page, setPage] = useState(1);
const [result, setResult] = useState<FavoritesList>(empty);
const [selected, setSelected] = useState<FavoriteDetail | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
listFavorites(moduleKey, search, trackedOnly, controller.signal, locationId, page, 50)
.then(setResult)
.catch((caught: unknown) => { if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : "Favorites could not be loaded."); });
return () => controller.abort();
}, [locationId, moduleKey, page, search, trackedOnly]);
function submit(event: FormEvent) { event.preventDefault(); setPage(1); setSearch(draft.trim()); }
async function open(recordId: string) { setSelected(await getFavorite(recordId)); }
return <section className="custom-favorites">
<h1>Favorites</h1>
<form role="search" onSubmit={submit}>
<label>Search<input value={draft} maxLength={200} onChange={event => setDraft(event.target.value)} /></label>
<label>Module<select value={moduleKey} onChange={event => { setModuleKey(event.target.value); setPage(1); }}><option value="">All modules</option>{result.modules.map(module => <option key={module.moduleKey} value={module.moduleKey}>{module.moduleName}</option>)}</select></label>
<label><input type="checkbox" checked={trackedOnly} onChange={event => { setTrackedOnly(event.target.checked); setPage(1); }} /> Tracked only</label><button>Search</button>
</form>
{error && <p role="alert">{error}</p>}
<div className="favorite-columns">
<ol>{result.items.map(item => <li key={item.recordId}><button type="button" onClick={() => void open(item.recordId)}><strong>{item.recordTitle || item.favoriteName}</strong><span>{item.moduleName} - {item.locationName || "All locations"}</span><small>{item.isPinned ? "Pinned" : "Saved"}{item.trackChanges ? " - Tracking changes" : ""}</small></button></li>)}</ol>
<article>{selected ? <><h2>{selected.recordTitle || selected.favoriteName}</h2><p>{selected.recordStatus || "No status"} - {selected.relationCount} related - {selected.activityCount} activity</p><ul>{selected.relatedRecords.map(related => <li key={related.recordRelationId}>{related.title || "Untitled"} ({related.moduleName})</li>)}</ul><dl>{selected.dynamicFields.map(field => <div key={field.fieldKey}><dt>{field.fieldLabel}</dt><dd>{favoriteDynamicValue(field)}</dd></div>)}</dl></> : <p>Select a favorite.</p>}</article>
</div>
<nav aria-label="Favorite pages"><button disabled={page <= 1} onClick={() => setPage(value => value - 1)}>Previous</button><span>Page {result.pageNumber} of {Math.max(1, result.totalPages)}</span><button disabled={page >= result.totalPages} onClick={() => setPage(value => value + 1)}>Next</button></nav>
</section>;
}
Pin, track, order, and remove
Preference updates address the canonical recordId and operate only on the current user's favorite. Other modules add a record with their own favorite endpoint; this workspace manages the resulting personal row.
import type { FavoriteDetail } from "@buildwithhq/module-sdk";
import { getFavorite, setFavorite, updateFavoritePreferences } from "./api";
export async function updateFavorite(current: FavoriteDetail, change: Partial<{ isPinned: boolean; trackChanges: boolean; sortOrder: number }>) {
await updateFavoritePreferences(current.recordId, {
isPinned: change.isPinned ?? current.isPinned,
trackChanges: change.trackChanges ?? current.trackChanges,
sortOrder: change.sortOrder ?? current.sortOrder,
});
return getFavorite(current.recordId);
}
export const pinFavorite = (current: FavoriteDetail) => updateFavorite(current, { isPinned: true });
export const trackFavorite = (current: FavoriteDetail) => updateFavorite(current, { trackChanges: true });
export async function removeFavorite(recordId: string) { return setFavorite(recordId, false); }
Optional starter styling
.custom-favorites { width: 100%; max-width: none; }
.custom-favorites form, .custom-favorites nav { align-items: end; display: flex; flex-wrap: wrap; gap: .75rem; }
.favorite-columns { display: grid; gap: 1rem; grid-template-columns: minmax(260px, 35%) minmax(0, 1fr); margin: 1rem 0; }
.favorite-columns ol { list-style: none; margin: 0; padding: 0; }
.favorite-columns li button { background: transparent; border: 0; display: grid; gap: .2rem; padding: .75rem; text-align: left; width: 100%; }
.favorite-columns article { border: 1px solid var(--line, #d8dee8); padding: 1rem; }
@media (max-width: 760px) { .favorite-columns { grid-template-columns: 1fr; } }
Exact data path
| Purpose | Route | Procedure |
|---|---|---|
| Personal list | GET /api/modules/favorites | sp_Favorites_ListSecured |
| Secured target detail | GET /api/modules/favorites/{recordId} | sp_Favorites_GetSecured |
| Current user's state | GET /api/modules/favorites/{recordId}/state | sp_Favorites_GetStateSecured |
| Add/remove | PUT /api/modules/favorites/{recordId}/favorite | sp_Favorites_SetSecured |
| Personal preferences | PUT /api/modules/favorites/{recordId}/preferences | sp_Favorites_UpdateSecured |
Record surfaces use the shared 25-by-25-pixel five-point star, gray when the record is not saved and gold when it is a favorite. If a record projection does not already include isFavorite, the shared component resolves it through the secured state route; successful changes synchronize every rendered copy of that record.
Lists are capped at 200 favorites per request. A favorite is navigation and personal preference, never an access grant, assignment, approval, or durable work queue.
Build a professional Favorites dashboard
These six registry-backed presentation blocks let a designer turn the secured Favorites API into a complete admin page without writing a chart, grid, status badge, empty state, or timeline from scratch. The normal operational API begins at /api/modules/favorites; a dashboard-wide count or trend should come from a separate purpose-built presentation binding so the browser never downloads a broad record population to calculate one number.
The ZIP contains this feature's fictional design composition at pages/first-class/favorites-sample.json, its executable authenticated page at pages/first-class/favorites-live.json, and the catalog-driven FirstClassPresentationGallery.tsx. Use the sample only for visual design. The live page calls the real native API and reviewed stored procedures under the signed-in user's scope.
| Payload kind | Runtime block | Useful Favorites projection |
|---|---|---|
metric-set | core.metric-strip | Favorite total, module mix, and recently changed items |
entity-list | core.entity-list | Saved records that remain authorized now |
progress-list | core.progress-list | Distribution by owning module or state |
series-chart | core.series-chart | Adds, removals, and changes over time |
data-grid | core.presentation-grid | One bounded cross-module favorite page |
timeline | core.timeline | Add, remove, source update, permission loss, and restoration |
Copy/paste design preview: all six blocks
This complete static page document renders immediately in Puck/Monaco and is useful while styling a template. Its names, counts, dates, and IDs are fictional design fixtures; static preview values are not live tenant facts.
Copy the complete six-block page JSON
{
"blocks": [
{
"_id": "favorites-metrics",
"_type": "core.metric-strip",
"props": {
"title": "Favorite records",
"asOfUtc": "2026-09-04T18:00:00Z",
"items": [
{
"key": "total",
"label": "Your favorites",
"value": 57,
"format": "number",
"tone": "primary"
},
{
"key": "contacts",
"label": "Contacts",
"value": 19,
"format": "number",
"tone": "neutral"
},
{
"key": "files",
"label": "Files",
"value": 11,
"format": "number",
"tone": "success"
},
{
"key": "changed",
"label": "Changed this week",
"value": 8,
"format": "number",
"tone": "warning"
}
]
},
"children": []
},
{
"_id": "favorites-recent",
"_type": "core.entity-list",
"props": {
"title": "Recently changed favorites",
"hasMore": true,
"items": [
{
"id": "favorites-sample-1",
"recordId": "favorites-record-1",
"primary": "Maya Chen",
"secondary": "Contact - Acme Field Services",
"status": {
"key": "updated",
"label": "Updated",
"tone": "primary"
},
"trailing": "Reno"
},
{
"id": "favorites-sample-2",
"recordId": "favorites-record-2",
"primary": "Maya Chen - Follow-up",
"secondary": "Contact - Acme Field Services - Updated two hours ago by the assigned owner",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"trailing": "Today"
},
{
"id": "favorites-sample-3",
"recordId": "favorites-record-3",
"primary": "Maya Chen - West region",
"secondary": "Contact - Acme Field Services - Related to three visible records at the Reno location",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"trailing": "3 related"
},
{
"id": "favorites-sample-4",
"recordId": "favorites-record-4",
"primary": "Maya Chen - Customer response",
"secondary": "Contact - Acme Field Services - Waiting for an external response before work can continue",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"trailing": "Tomorrow"
},
{
"id": "favorites-sample-5",
"recordId": "favorites-record-5",
"primary": "Maya Chen - Regional operations review with a deliberately long title",
"secondary": "Contact - Acme Field Services - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"status": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"trailing": "Review",
"tertiary": "Long-content fixture: verify keyboard focus, wrapping, narrow columns, and mobile overflow before publishing."
},
{
"id": "favorites-sample-6",
"recordId": "favorites-record-6",
"primary": "Maya Chen - Completed preview",
"secondary": "Contact - Acme Field Services - Closed after review with its related evidence retained",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"trailing": "Closed"
}
]
},
"children": []
},
{
"_id": "favorites-bystatus",
"_type": "core.progress-list",
"props": {
"title": "Favorites by module",
"items": [
{
"key": "contacts",
"label": "Contacts",
"value": 19,
"maximum": 45,
"displayValue": "19",
"tone": "primary",
"status": {
"key": "contacts",
"label": "Contacts",
"tone": "primary"
}
},
{
"key": "work",
"label": "Work orders",
"value": 15,
"maximum": 45,
"displayValue": "15",
"tone": "warning",
"status": {
"key": "work",
"label": "Work orders",
"tone": "warning"
}
},
{
"key": "files",
"label": "Files",
"value": 11,
"maximum": 45,
"displayValue": "11",
"tone": "success",
"status": {
"key": "files",
"label": "Files",
"tone": "success"
}
}
]
},
"children": []
},
{
"_id": "favorites-trend",
"_type": "core.series-chart",
"props": {
"title": "Favorite activity",
"variant": "bar",
"defaultPeriodKey": "d7",
"periods": [
{
"key": "d7",
"label": "7 days",
"labels": [
"Fri",
"Sat",
"Sun",
"Mon",
"Tue",
"Wed",
"Thu"
],
"series": [
{
"key": "primary",
"label": "Added",
"tone": "primary",
"values": [
8,
5,
4,
12,
15,
11,
17
]
},
{
"key": "secondary",
"label": "Removed",
"tone": "success",
"values": [
6,
4,
3,
9,
12,
10,
14
]
}
]
}
]
},
"children": []
},
{
"_id": "favorites-table",
"_type": "core.presentation-grid",
"props": {
"title": "Your visible favorites",
"columns": [
{
"key": "record",
"label": "Record",
"type": "text",
"align": "left"
},
{
"key": "module",
"label": "Module",
"type": "text",
"align": "left"
},
{
"key": "status",
"label": "Status",
"type": "status",
"align": "left"
},
{
"key": "location",
"label": "Location",
"type": "text",
"align": "left"
},
{
"key": "updated",
"label": "Updated",
"type": "date",
"align": "left"
}
],
"rows": [
{
"id": "favorites-row-1",
"recordId": "favorites-record-1",
"cells": {
"record": "Maya Chen",
"module": "Contacts",
"status": {
"key": "updated",
"label": "Updated",
"tone": "primary"
},
"location": "Reno",
"updated": "2026-09-04T12:36:00Z"
}
},
{
"id": "favorites-row-2",
"recordId": "favorites-record-2",
"cells": {
"record": "Maya Chen - Follow-up",
"module": "Contacts",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"location": "Reno",
"updated": "2026-09-04T15:42:00Z"
}
},
{
"id": "favorites-row-3",
"recordId": "favorites-record-3",
"cells": {
"record": "Maya Chen - West region",
"module": "Contacts",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"location": "Reno",
"updated": "2026-09-04T12:18:00Z"
}
},
{
"id": "favorites-row-4",
"recordId": "favorites-record-4",
"cells": {
"record": "Maya Chen - Customer response",
"module": "Contacts",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"location": "Reno",
"updated": "2026-09-03T21:07:00Z"
}
},
{
"id": "favorites-row-5",
"recordId": "favorites-record-5",
"cells": {
"record": "Maya Chen - Regional operations review with a deliberately long title",
"module": "Contacts - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"status": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"location": "Reno",
"updated": "2026-09-03T16:31:00Z"
}
},
{
"id": "favorites-row-6",
"recordId": "favorites-record-6",
"cells": {
"record": "Maya Chen - Completed preview",
"module": "Contacts",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"location": "Reno",
"updated": "2026-09-02T19:14:00Z"
}
}
],
"page": {
"pageNumber": 1,
"pageSize": 6,
"totalRecords": 57,
"totalIsExact": true,
"hasMore": true
}
},
"children": []
},
{
"_id": "favorites-timeline",
"_type": "core.timeline",
"props": {
"title": "Favorite activity",
"hasMore": true,
"items": [
{
"id": "favorites-event-1",
"recordId": "favorites-record-1",
"occurredUtc": "2026-09-04T17:58:00Z",
"title": "Favorite added",
"description": "North Wing closeout was added to your favorites.",
"actor": "You",
"tone": "success"
},
{
"id": "favorites-event-2",
"recordId": "favorites-record-2",
"occurredUtc": "2026-09-04T15:42:00Z",
"title": "Favorite added - Follow-up",
"description": "North Wing closeout was added to your favorites. Updated two hours ago by the assigned owner.",
"actor": "Avery Patel",
"tone": "primary"
},
{
"id": "favorites-event-3",
"recordId": "favorites-record-3",
"occurredUtc": "2026-09-04T12:18:00Z",
"title": "Favorite added - West region",
"description": "North Wing closeout was added to your favorites. Related to three visible records at the Reno location.",
"actor": "Sam Rivera",
"tone": "success"
},
{
"id": "favorites-event-4",
"recordId": "favorites-record-4",
"occurredUtc": "2026-09-03T21:07:00Z",
"title": "Favorite added - Customer response",
"description": "North Wing closeout was added to your favorites. Waiting for an external response before work can continue.",
"actor": "Maya Chen",
"tone": "warning"
},
{
"id": "favorites-event-5",
"recordId": "favorites-record-5",
"occurredUtc": "2026-09-03T16:31:00Z",
"title": "Favorite added - Regional operations review with a deliberately long title",
"description": "North Wing closeout was added to your favorites. This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"actor": "Automation",
"tone": "danger"
},
{
"id": "favorites-event-6",
"recordId": "favorites-record-6",
"occurredUtc": "2026-09-02T19:14:00Z",
"title": "Favorite added - Completed preview",
"description": "North Wing closeout was added to your favorites. Closed after review with its related evidence retained.",
"actor": "Jordan Lee",
"tone": "success"
}
]
},
"children": []
}
]
}Copy/paste React composition
Copy InteractivePresentationComponents.tsx and its optional CSS from the Professional Foundation Developer Kit, then add this module-specific composition. It is semantic and unstyled by default; pass styled after importing interactive-presentation-components.css for the supplied polished foundation. Either version accepts only a bounded already-authorized document and fails closed through the shared strict parsers.
import {
EntityList,
MetricStrip,
PresentationGrid,
ProgressList,
SeriesChart,
Timeline,
} from "./InteractivePresentationComponents";
export interface FavoritesPresentationData {
readonly metrics: unknown;
readonly recent: unknown;
readonly byStatus: unknown;
readonly trend: unknown;
readonly table: unknown;
readonly timeline: unknown;
}
export interface FavoritesPresentationProps {
/** Pass only the already-authorized presentation document returned by the API. */
readonly data?: FavoritesPresentationData | null;
readonly loading?: boolean;
readonly error?: boolean;
readonly styled?: boolean;
/** Record identity is navigation context; the detail API must authorize it again. */
readonly onOpenRecord?: (recordId: string) => void;
}
export function FavoritesPresentation({
data,
loading = false,
error = false,
styled = false,
onOpenRecord,
}: FavoritesPresentationProps) {
if (error) return <p role="alert">The Favorites presentation could not be loaded.</p>;
if (loading || !data) return <p role="status">Loading Favorites presentation...</p>;
return (
<main className={styled ? "bwhq-api-example" : undefined}>
<header>
<p>Favorites</p>
<h1>Favorite records</h1>
<p>The current user's saved records, module mix, recent changes, and fast navigation.</p>
</header>
<MetricStrip data={data.metrics} styled={styled} />
<div className={styled ? "bwhq-api-example__split" : undefined}>
<ProgressList data={data.byStatus} styled={styled} />
<EntityList data={data.recent} styled={styled} onOpenRecord={onOpenRecord} />
</div>
<SeriesChart data={data.trend} styled={styled} />
<PresentationGrid data={data.table} styled={styled} onOpenRecord={onOpenRecord} />
<Timeline data={data.timeline} styled={styled} onOpenRecord={onOpenRecord} />
</main>
);
}
Copy the live, authenticated module page
This document has no placeholder key and needs no invented endpoint. Save it to a page and add that page to a User Type menu. The registered native component calls /api/modules/favorites, uses the current tenant session, and preserves the module's real list, detail, create/update, pagination, empty, loading, and error behavior. Dynamic Records discovers the organization's real tenant-owned modules when no module key is configured.
Copy the executable live page
{
"blocks": [
{
"_id": "favorites-live",
"_type": "module.favorites",
"props": {},
"children": []
}
]
}The server derives SaaS app, organization, user, DataRoles, locations, module-specific membership/privacy, and any AI-read gate from verified identity. A returned identifier can select a detail target, but the detail or write endpoint authorizes it again. Page layout, status, tone, totals, action names, and identifiers never grant authority.
Favorites complement, but do not replace, queues or assignments. Use a workflow/Inbox item when work must be completed by a deadline or owner.
A renderer being bundled in the tenant application does not make its data visible in every app. The server returns only components and records authorized for the current app and signed-in user; unavailable or unauthorized blocks fail closed.
Capability review: 2026-09-14. For exact current technical availability, use the generated API Map and first-class module inventory.