App
Building dashboards
The dashboard recipe: a static shell, tiles as islands, one shared filter object, and server data that stays in sync without a store in sight.
On this page
A dashboard is the worst case for the classic choice. Render it static and it is dead weight; ship it as a SPA and you hydrate a whole app to show twelve mostly-read-only tiles. It is also the best case for ogygia, because a dashboard decomposes exactly along island lines; you just have to know which primitive owns which job.
When to use
This page is a recipe, not a new API. Every piece is covered in depth elsewhere; this is the map of how they compose into filters, cross-filtering tiles, live data, and drill-down, with the wiring done by the framework instead of by you.
The anatomy
| Dashboard piece | ogygia primitive |
|---|---|
| Shell: nav, headers, layout chrome | Plain page markup (zero JS), prerendered |
| Interactive tile: chart with tooltips, sortable table | Client island (wake) |
| Read-only tile: personalized or per-request HTML | Server island (render: 'deferred') |
| Heavy static content inside a tile | Lake (wake: 'none') |
| Global filters: date range, tenant, segment | One wire class provided via context |
| Tile data | Remote functions called inside the tile |
| Ticking tile: live numbers, feeds | query.live yielding held regions |
| Tile the server chooses | Held region from a registry |
The rest of this page builds a dashboard top-down through that table.
One filter object, everywhere
Every dashboard has one core gesture: change the date range, everything updates. Model the filters as a single wire class: all shared UI state goes on it, including cross-filter selections:
import * as ogygia from 'ogygia';
export class Filters {
range = $state('30d');
segment = $state<string | null>(null); // set by clicking a chart bar
static wire = import.meta.og.wire({
encode: (f: Filters) => $state.snapshot({ range: f.range, segment: f.segment }),
decode: (d) => Object.assign(new Filters(), d),
});
}
export const filtersCtx = ogygia.createContext<Filters>();Provide it once, above the tiles:
<!-- this page ships no JS of its own -->
<script>
import { Context } from 'ogygia';
import RangePicker from './RangePicker.svelte' with { wake: 'load' };
import Revenue from './Revenue.svelte' with { wake: 'load' };
import TopProducts from './TopProducts.svelte' with { wake: 'visible' };
import { Filters, filtersCtx } from './filters.svelte.js';
const filters = new Filters();
</script>
<Context of={filtersCtx} value={filters}>
<RangePicker />
<div class="grid">
<Revenue />
<TopProducts />
</div>
</Context>Each tile is a separate bundle, but they all hold one live object. The picker writes filters.range; every tile that read the context sees the change. No store library, no event bus, no prop threading: that is the wire contract doing its job. A visible tile that hydrates later joins the current instance, selections and all.
Tiles fetch their own data
A tile owns its query. Derive the call from the shared filters and the tile refetches itself whenever they change:
<script>
import { getStats } from './dashboard.remote';
import { filtersCtx } from './filters.svelte.js';
const filters = filtersCtx.get();
const stats = $derived(getStats(filters.range));
</script>
<article class:stale={stats.loading}>
{#if stats.current}
<h3>Revenue</h3>
<strong>{stats.current.revenue}</strong>
{/if}
</article>
<style>
.stale { opacity: 0.6; transition: opacity 150ms; }
</style>Three things you did not have to build:
- No refetch wiring.
$derivedre-runs the query whenfilters.rangechanges. That’s Svelte, not a dashboard framework. - No hydration flash. The SSR pass awaited the query on the server, and the result is seeded into the client cache, so the tile hydrates onto correct HTML without refetching (in production; dev degrades to a refetch).
- No loading-state plumbing. The query keeps
.current(the last settled value) while a new call is in flight, so the old number stays up and the tile just dims. Skeletons are for first paint, not for every filter change.
Cross-filtering is a field write. The bar chart sets filters.segment in its click handler; TopProducts includes filters.segment in its query args. Neither tile knows the other exists: the shared object is the coordination:
<!-- inside BarChart.svelte -->
<rect onclick={() => (filters.segment = bucket.name)} … />Shared data is deduped for you. Two tiles calling getStats(filters.range) with the same args share one cache entry and one flight: Kit’s query cache is app-wide, and islands share the one client runtime. Put the expensive aggregate in one query and let the KPI tile, the chart, and the table all read it; don’t build a distribution layer.
Tiles that ship zero JS
Not every tile earns hydration. A “recent orders” list nobody interacts with is a server island: per-request HTML, no client bundle:
<script>
import RecentOrders from './RecentOrders.svelte' with { render: 'deferred' };
import AuditLog from './AuditLog.svelte' with { render: 'deferred', wake: 'visible' };
</script>Each render: 'deferred' hole fetches its own HTML on load, and on a SPA navigation single-flight navigation pulls them all down one batch request, with no waterfall. render: 'deferred', wake: 'visible' tiles below the fold don’t even cost the server a render until scrolled to.
Two refinements for the tiles you do hydrate:
- Lakes for heavy innards. An interactive tile wrapping a big server-rendered SVG chart: mark the chart
wake: 'none'inside the island, and the controls hydrate while the chart’s markup never enters the client bundle.render: 'live'serves the cached chart instantly and refreshes it through the signed endpoint in the background. - Held regions when the server picks the tile. User-configurable dashboards: the backend knows the layout, the client holds no widget registry. A remote returns
region(…)per slot from a server-side registry, and the page renders<Region of={…}>for whatever arrives. The client never imports widgets the user didn’t place. See Held regions.
Note the trade: a server island’s props are captured at mint time: it re-renders per request, not per filter change. If a tile must react to filters, it is a client island with a query (above), which can still keep its heavy static parts in a lake.
Live tiles
For data that ticks (active users, a status feed), query.live pushes; the tile subscribes. Yield a held region each tick and the server renders the update:
export const activeUsers = query.live(v.string(), async function* (range) {
for await (const n of metrics(range)) yield region(LiveStat, { n });
});<Region of={activeUsers(filters.range).current} />A 'static' held region morphs in: focus, typed text, and open popovers survive. An interactive one is kept alive: new props are pushed into the mounted island, so its local state (a hovered point, an expanded row) survives the tick. That morph-don’t-replace behavior is most of what dashboard frameworks get wrong; here it is the default.
Drill-down and navigation
Dashboards navigate: overview → orders → order detail. With the SPA router, those are real routes (URLs, back button, load functions), not modal state.
- Filters can follow. Give the codec an
idand the same liveFiltersinstance survives navigation (session lifetime); the detail page opens already scoped to the range the user picked. Skip theidand filters reset per page. Both are one line; decide which your users expect. For shareable dashboards, mirror filters into the URL and merge inload. - Expensive tiles can survive.
keep: 'chart'relocates the live island across navigation instead of remounting: a rendered chart, its zoom level, its fetched data all carry over, with the new page’s props pushed in. - Half-filled forms already survive. A settings form abandoned mid-edit restores on back; continuity is ambient and on by default.
Don’t make yourself do the computer’s job
The checklist, inverted: if you are hand-building one of these, a primitive already owns it:
| If you’re writing… | Use instead |
|---|---|
| A store library or event bus between tiles | One wire class via context |
| Refetch-on-filter-change effects | $derived(query(filters.x)) |
| A “share this data between tiles” layer | One query, many readers; the cache dedupes |
| Skeleton/spinner state per tile | .current + .loading (stale-while-refetch is free) |
| WebSocket plumbing + diff/patch for live tiles | query.live yielding held regions (morph / keep-alive) |
| A client-side widget registry for user layouts | Held regions: the server picks, the client paints |
| Lazy tile loading | wake: 'visible' / render: 'deferred', wake: 'visible' |
| “Keep the chart alive across pages” hacks | keep |
Do / don’t
- Do put every piece of shared UI state (range, segment, selection) on the one wire class. Two sources of truth is how tiles drift.
- Do default tiles to
wake: 'visible'and below-fold server islands torender: 'deferred', wake: 'visible'; a dashboard’s fold hides most of its cost. - Do dim on
.loadinginstead of unmounting to a spinner; keeping the last value visible is what makes filter changes feel instant. - Don’t make a tile a server island because it “feels server-y”. If it must react to filters, it’s a client island with a query; the server-island trade is per-request HTML for zero JS.
- Don’t reach for
query.livefor data that only changes when the user acts; a plain query refetching via$derivedis cheaper and simpler. - Don’t verify data flows only in dev. SSR query seeding degrades to a refetch there; check a production build before judging flash. See Constraints & patterns.