Skip to main content
0.0.x

The page builder and the query island

This is the heaviest chapter in the track, and the most rewarding. You will render the orders table with NetScript's definePage builder — its layer / partial / island triad — and hydrate a TanStack Query island so the table reads and mutates on the client. By the end the dashboard renders instantly from cache and refetches in the background. We flag where the surface is conceptually dense.

  1. 1 · Scaffold
  2. 2 · Contract to service
  3. 3 · Cache-first query
  4. 4 · definePage + island
  5. 5 · Live stream
  6. 6 · Deploy

What you will build

An /dashboard/orders/ route that renders a filterable orders table — the screen the fulfillment team keeps open. The server shell is a definePage page with a cache-first list layer; the interactive part is a QueryIsland that reads through the chapter-3 query helpers with useQuery and advances order status with an optimistic useMutation. Optimistic matters here: when a packer marks an order shipped, the row must move instantly — a badge that lags invites clicking it twice, and a double-advanced order is exactly the kind of quiet mistake this dashboard exists to prevent. You end with a page that paints from KV cache on first byte and stays live on the client.

Before you begin

You should have completed chapter 3: apps/dashboard/lib/orders.ts exports ordersClient and ordersQueries, and deno task check is clean. Confirm the query module is in place:

deno check apps/dashboard/lib/orders.ts --unstable-kv

A clean check means the typed client and query factory are ready to wire into a page.

The mental model: layer / partial / island

Before any code, hold these three words apart — most of the chapter is just them working together:

The definePage triad
NameTypeDescription
Layer withLayer(name, Component, config) A named region of the page. Each layer has its own server loader, its own fallback skeleton, and its own staleness window. The page is a composition of layers.
Partial partial + partialName on a layer The Fresh partial route a layer re-renders through. It lets one layer refresh on the server without a full page navigation — the cache-first refresh path.
Island a layer whose Component is a Fresh island An interactive layer that hydrates in the browser. Here it is the QueryIsland: client-side reads, refetch, and optimistic mutations.

A definePage page wires several layers into a layout, each fed by its own loader. The server renders every layer from cache; the island layer then takes over interactivity in the browser.

Step 1 — Declare the route contract

A NetScript route declares its own typed search params. defineRouteContract from @netscript/fresh/route builds that schema; paginationSearchSchema and fallback give you safe defaults for missing or malformed query strings. Create the route file:

// apps/dashboard/routes/(dashboard)/dashboard/orders/index.route.ts
import { defineRouteContract, fallback, paginationSearchSchema } from '@netscript/fresh/route';
import { z } from 'zod';

const ORDERS_SEARCH_SCHEMA = paginationSearchSchema({
  defaultSort: 'createdAt',
  defaultOrder: 'desc',
}).extend({
  search: fallback(z.string(), ''),
  status: fallback(z.enum([
    'pending', 'processing', 'shipped', 'delivered', 'cancelled', 'returned', 'failed',
  ]).optional(), undefined),
});

export default defineRouteContract({ searchSchema: ORDERS_SEARCH_SCHEMA });

// The parsed shape every loader and island receives — page, limit, offset, sortBy,
// sortOrder, plus the search/status fields the schema extends with.
export type OrdersSearch = ReturnType<typeof ORDERS_SEARCH_SCHEMA.parse>;

fallback(schema, default) is the safety belt: a junk ?status=banana resolves to the default instead of throwing, so a hand-edited URL never 500s the page. This is the same paginationSearchSchema() the framework uses site-wide — every route that paginates parses limit and offset through it rather than reading searchParams by hand.

Step 2 — Define the page and cache-first resource pipeline

The page reads data through request-scoped resource factories. .withResource(name, factory) registers a value that is computed once while the page renders, no matter how many layers ask for it. That matters here because two layers want the same cached orders slice: the server-rendered list table and the ordersQuery island seed. Declared as a resource, the KV read happens once and both layers share it. Downstream resources may await upstream ones, so the prefetch step below builds on the same typed search input — the resolution order, the shared store, and the dedup spans behind that are in Request-scoped resources:

// apps/dashboard/routes/(dashboard)/dashboard/orders/index.tsx
import { definePage } from '@app/utils.ts';
import { dehydrateQueryClient } from '@netscript/fresh/query';
import { createNetScriptQueryClient } from '@netscript/sdk/query-client';
import { ordersQueries } from '@app/lib/orders.ts';
import { routes } from '@app/router.ts';
import OrdersQueryIsland from './(_islands)/OrdersQueryIsland.tsx';
import StatsLayer from './(_components)/StatsLayer.tsx';
import { PlaygroundOrdersList, PlaygroundOrdersListSkeleton } from './(_components)/list.tsx';

export const ordersListPage = definePage()
  .withRoute(routes.dashboard.orders.$route)
  .withPolicy('balanced')
  .withTelemetry({ enabled: true, spanName: 'dashboard.orders.list' })
  // Read once per request; the list layer and the island layer both consume this.
  .withResource('ordersData', async (ctx) => {
    return await ordersQueries.list.getCachedEntry({
      limit: ctx.search.limit,
      offset: ctx.search.offset,
      status: ctx.search.status,
    });
  })
  .withResource('dehydratedQuery', async (ctx) => {
    const queryClient = createNetScriptQueryClient();
    await queryClient.prefetchQuery(ordersQueries.list.queryOptions({
      limit: ctx.search.limit,
      offset: ctx.search.offset,
      status: ctx.search.status,
    }));
    return dehydrateQueryClient(queryClient);
  })

definePage comes from @app/utils.ts, not straight from @netscript/fresh/builders. Your scaffold wrote that module in chapter 1 — a thin wrapper that calls the package builder with the app's State type applied (export function definePage() { return createDefinePage<State>(); }), so every page in the app shares one typed context. Import the package builder directly and you lose that binding.

spanName: 'dashboard.orders.list' is not decoration: every render of this page emits a span under that name, and it shows up in the Aspire dashboard's traces view alongside the service call the loader made. When the table feels slow, that trace is where you find out whether the time went to KV, to the orders service, or to the render itself.

By defining dehydratedQuery as a shared resource, you prefetch orders on the server and serialise the cache. It is sent to the client alongside the initial HTML, eliminating the browser refetch flash.

Step 3 — Add layers and partials

Now compose the visual regions. You add the server-rendered table, the interactive query island, and a stats panel loaded asynchronously through a deferred partial — then lay them out and build(). The three layers resolve concurrently, so the page costs its slowest region rather than the sum — the loader contract, the full layer config, and slot placement are in Layers, layout, and slots. The partial and partialName entries below are what turn a layer into a refreshable region; Partials covers the partial route on the other end:

// apps/dashboard/routes/(dashboard)/dashboard/orders/index.tsx (continued)
  .withLayer('list', PlaygroundOrdersList, {
    loader: async (ctx) => {
      const cachedEntry = await ctx.resource('ordersData');
      if (!cachedEntry) return undefined; // cold cache → fallback skeleton
      return { data: cachedEntry.data, cachedAt: cachedEntry.cachedAt };
    },
    partial: routes.partials.dashboard.orders.list.$route.href(),
    partialName: 'orders-list',
    fallback: <PlaygroundOrdersListSkeleton />,
    staleTime: 15_000,
    staleReloadMode: 'background',
  })
  .withLayer('ordersQuery', OrdersQueryIsland, {
    loader: async (ctx) => {
      // Same resource the list layer read — resolved once, shared here.
      const entry = await ctx.resource('ordersData');
      const dehydratedState = await ctx.resource('dehydratedQuery');
      return {
        dehydratedState,
        input: {
          limit: ctx.search.limit,
          offset: ctx.search.offset,
          status: ctx.search.status,
        },
        initialOrders: entry?.data,
        cachedAt: entry?.cachedAt,
      };
    },
    staleTime: 15_000,
    staleReloadMode: 'background',
  })
  .withLayer('stats', StatsLayer, {
    loader: (ctx) => {
      // Not awaited: the promise is handed to the layer and resolves in the background.
      const statsPromise = ordersQueries.getStats({ status: ctx.search.status });
      return { statsPromise };
    },
    partial: routes.partials.dashboard.orders.stats.$route.href(),
    partialName: 'orders-stats',
  })
  .withLayout((slots) => (
    <main class='ns-page-end'>
      <div class='ns-stack ns-stack--lg'>
        {slots.stats()}
        {slots.list()}
        {slots.ordersQuery()}
      </div>
    </main>
  ))
  .withMeta(() => ({
    title: 'Order Queue',
    description: 'Browse and manage orders in the live dashboard.',
  }))
  .build();

export const { handler, default: page } = ordersListPage;
export { page as default };

Read the builder one call at a time:

definePage builder steps
NameTypeDescription
.withRoute(route) route contract Binds the typed search schema from Step 1. The loaders receive a typed search object.
.withPolicy('balanced') caching policy The page's caching posture. 'balanced' serves cache-first and revalidates in the background.
.withTelemetry({ enabled, spanName }) tracing Wraps the page render in a named span that surfaces in the Aspire dashboard traces.
.withResource(name, factory) request-scoped value Computes a value once per page render. Layers read it with ctx.resource(name), so two layers reading the same slice cost one fetch.
.withLayer(name, Component, config) a named region Adds a layer with its own loader, partial, fallback, and staleTime. Call it once per region.
.withLayout(slots => …) layout callback Places each layer by calling slots.(). The layout is plain JSX.
.withMeta(() => …) head metadata Page title and description.
.build() finalize Produces the page object: { handler, default } that Fresh serves.

Step 4 — Hydrate the QueryIsland client-side

The client-side island receives the server-prefetched query state as props. You call hydrateFromDehydrated during mount to warm up the client cache, allowing useQuery to resolve without hitting the network:

// apps/dashboard/routes/(dashboard)/dashboard/orders/(_islands)/OrdersQueryIsland.tsx
import { useRef } from 'preact/hooks';
import {
  getIslandQueryClient,
  hydrateFromDehydrated,
  invalidateServerQueryCache,
  QueryIsland,
  useMutation,
  useQuery,
  useQueryClient,
} from '@netscript/fresh/query';
import { ordersQueries } from '@app/lib/orders.ts';

function OrdersQueryInner(props) {
  const queryClient = useQueryClient();
  const listOptions = ordersQueries.list.queryOptions(props.input);
  const currentKey = listOptions.queryKey;
  const hydratedRef = useRef(false);

  // Warm the client cache from the server-dehydrated state, once, before first render.
  if (!hydratedRef.current && props.dehydratedState) {
    hydrateFromDehydrated(getIslandQueryClient(), props.dehydratedState);
    hydratedRef.current = true;
  }

  // Resolves instantly from the hydrated cache (no spinner, no flash)
  const { data: orders, isRefetching } = useQuery({
    ...listOptions,
    initialData: props.initialOrders,
    initialDataUpdatedAt: props.cachedAt,
    staleTime: 15_000,
  });

  // Optimistic status advance — update the cache, roll back on error.
  const statusMutation = useMutation({
    ...ordersQueries.update.mutationOptions(),
    onMutate: async (variables) => {
      await queryClient.cancelQueries({ queryKey: currentKey });
      const previous = queryClient.getQueryData(currentKey);
      queryClient.setQueryData(currentKey, (prev) => applyStatus(prev, variables));
      return { previous };
    },
    onError: (_e, _v, ctx) => {
      if (ctx?.previous) queryClient.setQueryData(currentKey, ctx.previous);
    },
    onSuccess: async () => {
      // Invalidate the server tier first, so a reload cannot repaint stale KV data.
      await invalidateServerQueryCache(ordersQueries.list.key(props.input));
      await queryClient.invalidateQueries({ queryKey: ordersQueries.list.clientKey() });
    },
  });

  const items = orders?.items ?? [];
  return <OrdersTable items={items} isRefetching={isRefetching} onAdvance={statusMutation.mutate} />;
}

export default function OrdersQueryIsland(props) {
  return (
    <QueryIsland>
      <OrdersQueryInner {...props} />
    </QueryIsland>
  );
}

One constraint makes this work: the hydrated entries land in the island's shared QueryClient under the exact query keys the server used, so useQuery only benefits if queryOptions(props.input) produces the same key the server prefetched. initialData comes from the explicit initialOrders prop and wins the mount-time handoff even if the shared client already contains an older entry; initialDataUpdatedAt preserves the KV entry's real age. Later optimistic writes and refetches win over that seed.

The mutation clears two distinct tiers. invalidateServerQueryCache() reaches the JSON-only route that defineFreshApp() registers automatically, using .key(props.input) for the serialized KV key. Only after that succeeds does invalidateQueries() refresh the browser tier. No product-owned API route is required, and a reload between the two cannot read around the entry just invalidated.

Step 5 — Render the Deferred stats layer

To display the stats layer that loaded asynchronously, use the <Deferred> component. It acts as a suspense boundary, wrapping the promise and rendering a fallback placeholder while it resolves:

// apps/dashboard/routes/(dashboard)/dashboard/orders/(_components)/StatsLayer.tsx
import { Deferred } from '@netscript/fresh/defer';

interface StatsProps {
  statsPromise: Promise<{ totalRevenue: number; ordersCount: number }>;
}

export default function StatsLayer(props: StatsProps) {
  return (
    <Deferred
      promise={props.statsPromise}
      fallback={<div class="ns-skeleton">Loading statistics...</div>}
    >
      {(data) => (
        <div class="ns-stats-grid">
          <div class="ns-card">
            <h4>Total Revenue</h4>
            <p>${data.totalRevenue}</p>
          </div>
          <div class="ns-card">
            <h4>Orders Count</h4>
            <p>{data.ordersCount}</p>
          </div>
        </div>
      )}
    </Deferred>
  );
}

Verify your progress

Make sure aspire start is up, then open the route in a browser at /dashboard/orders/.

You need the app's port to do that, and there is no number to memorize: a scaffolded Fresh app pins no host port, so Aspire allocates one at runtime. The Aspire dashboard resource list is the authority — find the dashboard resource, click its endpoint, and append /dashboard/orders/. You should see the orders table render immediately — populated from KV cache, not a spinner — and a "Refreshing" indicator flicker as it revalidates. Advancing an order's status should update its badge instantly. Type-check too:

deno task check
  • [ ] index.route.ts, index.tsx, (_components)/StatsLayer.tsx, and (_islands)/OrdersQueryIsland.tsx all exist under apps/dashboard/routes/(dashboard)/dashboard/orders/.
  • [ ] The page renders the orders table from cache on first paint (no spinner flash).
  • [ ] Advancing a status updates the row optimistically.
  • [ ] deno task check is clean.

What you built

A definePage orders page that renders cache-first through the layer/partial/island triad, plus a hydrated QueryIsland that reads with useQuery and mutates optimistically with useMutation — all keyed off the same contract-derived helpers. The table is live on the client. Next you make it live from the server: rows pushed into an open page over a durable StreamDB.