Skip to content

App

Building dashboards

A recipe for dashboards with a static shell, tiles as islands, one shared filter object and server data that stays in sync, without a store library.

On this page

Dashboards are the worst case for the usual choice between a static page and an SPA. Rendered statically, a dashboard is dead weight, but shipped as an SPA it hydrates a whole application to show a dozen tiles that are mostly read-only. They are also the best case for ogygia, because a dashboard divides exactly along island lines, and the only real work is knowing which primitive owns which job. This page introduces no new API. It is a map of how the existing pieces, each documented on its own page, combine into filters, cross-filtering tiles, live data and drill-down.

The anatomy

Dashboard pieceogygia primitive
Shell: nav, headers, layout chromePlain page markup (zero JS), prerendered
Interactive tile: chart with tooltips, sortable tableClient island (wake)
Read-only tile: personal or per-request HTMLServer island (render: 'deferred')
Heavy static content inside a tileLake (wake: 'none')
Global filters: date range, tenant, segmentOne wire class provided through context
Tile dataRemote functions called inside the tile
Ticking tile: live numbers, feedsquery.live that yields held regions
Tile that the server selectsHeld region from a registry

The sections below build a dashboard from this table, from the top down.

One filter object, everywhere

Every dashboard has one core gesture: you change the date range, and all the tiles update. Model the filters as one wire class, and 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 once, 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 of them hold one live object. When the picker writes filters.range, every tile that read the context sees the change, without a store library, an event bus or props threaded through the tree. A visible tile that hydrates later joins the current instance, selections included. State & context explains how the wire contract makes this work.

Tiles fetch their own data

Each tile owns its query. If you derive the call from the shared filters, the tile fetches again by itself whenever 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>

Several things here come without any work on your part:

  • No refetch wiring. $derived runs the query again when filters.range changes, which is plain Svelte rather than 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 fetching again. This holds in production; in dev it 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 on screen and the tile only dims. Skeletons are for the first paint, not for every filter change.

Cross-filtering is a field write. The bar chart sets filters.segment in its click handler, and TopProducts includes filters.segment in its query arguments. The two tiles know nothing about each other, because the shared object does the coordinating:

<!-- inside BarChart.svelte -->
<rect onclick={() => (filters.segment = bucket.name)} … />

Shared data is deduplicated for you. Two tiles that call getStats(filters.range) with the same arguments share one cache entry and one request, because Kit’s query cache is app-wide and all 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, instead of building a distribution layer.

Tiles that ship zero JS

Not every tile needs hydration. A “recent orders” list that nobody interacts with can be a server island, which is per-request HTML with no client bundle:

<script>
  import RecentOrders from './RecentOrders.svelte' with { render: 'deferred' };
  import AuditLog from './AuditLog.svelte' with { render: 'deferred', fetch: 'visible' };
</script>

Each render: 'deferred' hole fetches its own HTML on load, and on an SPA navigation single-flight navigation pulls all of them down in one batch request. Tiles below the fold with render: 'deferred', fetch: 'visible' cost the server no render until you scroll to them.

For the tiles you do hydrate, two refinements help:

  • Lakes for heavy content inside. An interactive tile can hold a large server-rendered SVG chart. If you mark the chart wake: 'none' inside the island, the controls hydrate while the chart’s markup never enters the client bundle, and render: 'live' can refresh it in the background. See Lakes.
  • Held regions when the server chooses the tile. On a dashboard users can configure, the backend knows the layout, so the client needs no widget registry. A remote returns region(…) for each slot from a server-side registry, the page renders <Region of={…}> for whatever arrives, and the client never imports widgets the user did not place. See Held regions.

Keep the trade-off in mind: a server island’s props are captured when it is signed, and it renders again per request, not per filter change. A tile that has to react to filters is a client island with a query (as above), which can still keep its heavy static parts in a lake.

Live tiles

For data that ticks, such as active users or a status feed, query.live pushes updates and the tile subscribes. If you yield a held region on each tick, 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 raw (HTML-only) held region morphs in, so focus, typed text and open popovers stay. An interactive one is kept alive and receives the new props in the mounted island, so its local state, such as a hovered point or an expanded row, survives the tick. Held regions covers this in detail.

Drill-down and navigation

Dashboards navigate, for example from an overview to orders to an order’s detail. With the SPA router, those are real routes with URLs, a working back button and load functions, rather than modal state.

  • Filters can follow you. Give the codec an id, and the same live Filters instance survives navigation for the length of the session, so the detail page opens already set to the range the user chose. Without the id, filters reset on each page. Either is one line, so choose the behaviour your users expect. For dashboards people share, mirror the filters into the URL and merge them in load. State & context explains the id.
  • Expensive tiles can carry over. keep: 'chart' moves the live island across a navigation instead of remounting it, so a rendered chart keeps its zoom level and fetched data, and the new page’s props are pushed in.
  • Half-filled forms already carry over. A settings form left mid-edit is restored when you come back, because continuity is on by default.

What you don’t need to build

If you find yourself building one of these by hand, a primitive already covers it:

If you’re writing…Use instead
A store library or an event bus between tilesOne wire class through context
Refetch-on-filter-change effects$derived(query(filters.x))
A “share this data between tiles” layerOne 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 tilesquery.live that yields held regions (morph / keep-alive)
A client-side widget registry for user layoutsHeld regions: the server selects, the client paints
Lazy tile loadingwake: 'visible' / render: 'deferred', fetch: 'visible'
“Keep the chart alive across pages” hackskeep

Do / don’t

  • Do put every piece of shared UI state (range, segment, selection) on the one wire class, because two sources of truth make tiles drift apart.
  • Do set tiles to wake: 'visible' and below-the-fold server islands to render: 'deferred', fetch: 'visible'. Most of a dashboard’s cost is below the fold.
  • Do dim on .loading instead of unmounting to a spinner. Keeping the last value visible is what makes filter changes feel immediate.
  • Don’t make a tile a server island because it “feels server-like”. If it has to react to filters, it is a client island with a query, since a server island trades reactivity for per-request HTML with no JavaScript.
  • Don’t use query.live for data that only changes when the user acts. A plain query that refetches through $derived is cheaper and simpler.
  • Don’t judge data flows only in dev, where SSR query seeding degrades to a refetch. Check a production build before you judge any flash. See Constraints & patterns.