Dashboard Widgets
A widget is a small React component your app contributes to the home dashboard (/dashboard) — a compact, read-only summary of your app's data (items left to buy, upcoming events, perks due soon) with a link that drills into the full app. Add one when your app has an at-a-glance number or list worth surfacing on the home screen.
Table of Contents
How widgets work
Widgets are self-contained components your app declares in its app.config.ts. The dashboard discovers them from every installed app's config, sorts them by order (lower first), and renders each in a vertical stack — filtered to the apps the viewer can access and reordered per the viewer's saved preferences. Your widget fetches its own data and receives no props. Declaring the widget is enough for it to appear; you don't touch any dashboard or registry code.
Add a Widget to Your App
Two steps: write the component, then register it in app.config.ts. Say you want a recipes widget showing meals cooked this week.
1. Write the widget component
Put it under your app's components/ folder, one file per widget named <Name>Widget.tsx:
packages/homestead-apps/recipes/components/RecipesCookedThisWeekWidget.tsxA widget takes no props, fetches its own data via an app-scoped hook, wraps its content in <WidgetCard> for consistent chrome, and handles the loading and empty states explicitly:
import { ChefHat, Loader2 } from 'lucide-react';
import { WidgetCard } from '@rambleraptor/homestead-core/shared/components/WidgetCard';
import { useRecipeLogs } from '../hooks/useRecipeLogs';
export function RecipesCookedThisWeekWidget() {
const { data: logs, isLoading } = useRecipeLogs({ since: 'this-week' });
const count = logs?.length ?? 0;
return (
<WidgetCard
icon={ChefHat}
title="Cooked this week"
href="/recipes"
data-testid="recipes-cooked-this-week-widget"
>
{isLoading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="w-6 h-6 text-text-muted animate-spin" />
</div>
) : count > 0 ? (
<div className="flex items-baseline gap-2 py-2">
<span className="font-display text-3xl text-text-main">{count}</span>
<span className="font-body text-text-muted">
{count === 1 ? 'meal cooked' : 'meals cooked'}
</span>
</div>
) : (
<p className="font-body text-text-muted py-2">
No meals logged yet this week.
</p>
)}
</WidgetCard>
);
}Note the imports: shared chrome comes from the @rambleraptor/homestead-core/... alias, while the app's own hook is a relative ../hooks/... import. If the data hook you need doesn't exist, add it under packages/homestead-apps/<feature>/hooks/ first; reuse an existing hook when one already covers your data. There is no 'use client' directive — this is a Vite + React SPA, not Next.js.
2. Register the widget in app.config.ts
Append it to the app's widgets array. The component is a lazy thunk (() => import(...).then((m) => m.X)), matching how routes and icons are declared. Pick a globally unique id (prefix with the app id), give it a human-readable label (shown in the dashboard customization UI), and choose an order that positions it relative to widgets from other apps:
// packages/homestead-apps/recipes/app.config.ts
export const recipesApp: AppConfig = {
// ...existing fields...
widgets: [
{
id: 'recipes-cooked-this-week',
label: 'Cooked this week',
component: () =>
import('./components/RecipesCookedThisWeekWidget').then(
(m) => m.RecipesCookedThisWeekWidget,
),
order: 30,
},
],
};For reference, here is the groceries app's declaration:
// packages/homestead-apps/groceries/app.config.ts
widgets: [
{
id: 'groceries-remaining',
label: 'Groceries',
component: () =>
import('./components/GroceriesWidget').then((m) => m.GroceriesWidget),
order: 10,
},
],That's it — the dashboard discovers your widget automatically; no registry edits needed.
3. Verify and run the gate
make dev
# or, for the full stack: bun packages/homestead-cli/src/cli.ts start --devVisit /dashboard and confirm the widget appears in the right slot relative to other widgets, then run make ci && make test. If you add an e2e check, follow the existing widget testid pattern (<feature>-widget or <feature>-<slug>-widget) so the Page Object can target it without CSS selectors.
WidgetCard
<WidgetCard> is the standard wrapper that gives every widget a consistent look (rounded card, icon chip, link-style title, optional config gear, collapse toggle). The props you'll use:
export interface WidgetCardProps {
icon?: LucideIcon; // header chip icon
title: ReactNode; // shown inside the link
href: string; // app home route
configHref?: string; // optional config page; renders a gear
configLabel?: string; // a11y label for the gear; defaults to "Configure widget"
children?: ReactNode; // body, hidden when collapsed
defaultCollapsed?: boolean; // default false
className?: string; // outer card extras
bodyClassName?: string; // body wrapper extras
'data-testid'?: string; // outer card test id
}Notes:
- The title is wrapped in a react-router
<Link to={href}>(fromreact-router-dom, notnext/link). Pointhrefat your app's home route so users can drill in by clicking the title. - When
configHrefis set, a settings gear renders in the header linking to the widget's configuration page (also a react-router<Link>). It exposesdata-testid="widget-config-link". - Collapse state is local to the widget instance (not persisted). Keep
defaultCollapsedfalseunless the body is expensive or noisy. - The collapse toggle exposes
data-testid="widget-collapse-toggle"; if you interact with it in e2e tests, scope the lookup to your widget's outer testid.
Conventions and Gotchas
Naming
- Component file:
<Name>Widget.tsxunderpackages/homestead-apps/<feature>/components/. - Widget id:
<app-id>-<slug>(e.g.groceries-remaining,events-upcoming). Ids must be unique across the whole app. - Test id: same as the widget id with a
-widgetsuffix, or just<app>-widgetwhen the app only has one.
Order values
order controls global widget ordering, not per-app. Choose values with gaps (10, 20, 30, …) so future widgets can slot in without renumbering. The default is 100; widgets without an explicit order land at the bottom in declaration order. The viewer can override this ordering (and hide widgets) through the dashboard customization UI, so order is the default, not a guarantee.
Data fetching
- Use a React Query hook from your app's
hooks/directory. The widget benefits from the same cache as the rest of the app — the dashboard won't refetch data the user already loaded elsewhere. Prefer queries the rest of the app already runs; cheap fetches matter since widgets load on the dashboard regardless of which app the user opens next. - Always render an
isLoadingbranch and an empty branch. Avoid showing0with no context; phrase the empty state in plain English.
Visuals
- Wrap the body in
<WidgetCard>rather than a custom container — the dashboard relies on consistent card geometry. - Keep widgets compact. The dashboard column is
max-w-3xl; widgets that need more space should link out to a full-page view via the titlehref. - Use the project palette (
text-text-main,text-text-muted,font-display,font-body,bg-surface-white,text-brand-navy, etc.).
Don'ts
- Don't accept props on a widget component. The contract is zero-prop components. If a widget needs configuration, wire it through app flags (
AppConfig.flags) and read the value withuseAppFlag(...)from@rambleraptor/homestead-core/settings. - Don't import another app's components into your widget. Apps stay self-contained.
- Don't write data from a widget. Widgets are read-only summaries; provide a CTA that links into the app for mutations.
- Don't import eagerly — keep the lazy
component: () => import(...)form so your widget code stays code-split out of the main bundle. - Don't add
'use client'or import fromnext/*— this is a Vite SPA.
Existing Widgets
The widgets already in the repo are good references when you write your own (e.g. packages/homestead-apps/groceries/components/GroceriesWidget.tsx). Find the current list with:
grep -rn "widgets:" packages/homestead-apps/*/app.config.ts