App
Building dashboards
The dashboard recipe. A static shell, tiles as islands, one shared filter object, and server data that stays in sync, with no store library.
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 full app to show twelve tiles that are almost read-only. It is also the best case for ogygia. A dashboard divides exactly along island lines. You only must know which primitive owns which job.
When to use
This page is a recipe, not a new API. Each piece has its own page with full depth. This is the map of how they combine into filters, cross-filter tiles, live data, and drill-down. The framework does the wiring, not 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: personal 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 through context |
| Tile data | Remote functions called inside the tile |
| Ticking tile: live numbers, feeds | query.live that yields held regions |
| Tile that the server selects | Held region from a registry |
The remainder of this page builds a dashboard through that table, from the top down.
One filter object, everywhere
Each dashboard has one core gesture: change the date range, and all tiles update. Model the filters as one wire class. Put all shared UI state 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>('filters');Provide it one time, above the tiles:
<!-- this page ships no JS of its own -->
<script>
import { Provide } 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>
<Provide values={[filtersCtx(filters)]}>
<RangePicker />
<div class="grid">
<Revenue />
<TopProducts />
</div>
</Provide>Each tile is a separate bundle. But all tiles hold one live object. The picker writes filters.range. Each tile that read the context sees the change. There is no store library, no event bus, and no prop threading. That is the wire contract at work. A visible tile that hydrates later joins the current instance, with its selections.
Tiles fetch their own data
A tile owns its query. Derive the call from the shared filters. Then the tile fetches itself again when the filters 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.
$derivedruns the query again whenfilters.rangechanges. That is Svelte, not a dashboard framework. - No hydration flash. The SSR pass awaited the query on the server. The result is seeded into the client cache. Thus the tile hydrates onto correct HTML and does not fetch again. This applies to 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. The old number stays up, and the tile only dims. Skeletons are for first paint, not for each filter change.
Cross-filter is a field write. The bar chart sets filters.segment in its click handler. TopProducts includes filters.segment in its query args. The two tiles do not know each other. The shared object is the coordination:
<!-- inside BarChart.svelte -->
<rect onclick={() => (filters.segment = bucket.name)} … />Shared data dedupes for you. Two tiles that call getStats(filters.range) with the same args share one cache entry and one flight. The query cache of Kit is app-wide, and islands share the one client runtime. Put the costly aggregate in one query. Let the KPI tile, the chart, and the table read it. Do not build a distribution layer.
Tiles that ship zero JS
Not each tile earns hydration. A “recent orders” list that no person touches 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. On a SPA navigation, single-flight navigation pulls all holes down in one batch request, with no waterfall. Tiles below the fold with render: 'deferred', wake: 'visible' do not cost the server a render until you scroll to them.
Two refinements for the tiles that you do hydrate:
- Lakes for heavy content inside. An interactive tile can hold a large server-rendered SVG chart. Mark the chart
wake: 'none'inside the island. The controls hydrate. The markup of the chart never enters the client bundle.render: 'live'serves the cached chart immediately and refreshes it in the background through the signed endpoint. - Held regions when the server selects the tile. For 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. The page renders<Region of={…}>for the value that arrives. The client never imports widgets that the user did not place. See Held regions.
Know the trade: the props of a server island are captured at signing time. It renders again per request, not per filter change. If a tile must react to filters, it is a client island with a query (above). That island can still keep its heavy static parts in a lake.
Live tiles
For data that ticks (active users, a status feed), query.live pushes, and the tile subscribes. Yield a held region on 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 stay. An interactive one is kept alive: the new props push into the mounted island. Thus its local state (a hovered point, an expanded row) continues through the tick. Dashboard frameworks usually get this morph behavior 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, the back button, load functions. They are not modal state.
- Filters can follow. Give the codec an
id, and the same liveFiltersinstance continues through navigation (session lifetime). The detail page opens already set to the range that the user selected. Without theid, filters reset per page. Both are one line. Decide which one your users expect. For dashboards that people share, mirror the filters into the URL and merge them inload. - Costly tiles can continue.
keep: 'chart'moves the live island across a navigation. It does not remount. A rendered chart, its zoom level, and its fetched data all carry over. The props of the new page push in. - Half-filled forms already continue. A settings form left in mid-edit restores on back. Continuity is ambient and on by default.
Don’t do the computer’s job
The checklist, inverted. If you build one of these by hand, a primitive already owns it:
| If you’re writing… | Use instead |
|---|---|
| A store library or an event bus between tiles | One wire class through 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 or spinner state per tile | .current + .loading (stale-while-refetch comes free) |
| WebSocket plumbing plus diff/patch for live tiles | query.live that yields held regions (morph / keep-alive) |
| A client-side widget registry for user layouts | Held regions: the server selects, 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 each piece of shared UI state (range, segment, selection) on the one wire class. Two sources of truth make tiles drift.
- Do set tiles to
wake: 'visible', and below-fold server islands torender: 'deferred', wake: 'visible'. The fold hides most of the cost of a dashboard. - Do dim on
.loading. Do not unmount to a spinner. The visible last value is what makes filter changes feel immediate. - Don’t make a tile a server island because it “feels server-like”. If it must react to filters, it is a client island with a query. The server-island trade is per-request HTML for zero JS.
- Don’t use
query.livefor data that changes only when the user acts. A plain query that refetches through$derivedcosts less and is simpler. - Don’t check data flows only in dev. SSR query seeding degrades to a refetch there. Check a production build before you judge flash. See Constraints & patterns.