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 · Scaffold
- 2 · Contract to service
- 3 · Cache-first query
- 4 · definePage + island
- 5 · Live stream
- 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:
| Name | Type | Description |
|---|---|---|
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:
| Name | Type | Description |
|---|---|---|
.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. |
.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.tsxall exist underapps/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 checkis 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.