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 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. 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/api-clients.ts exports ordersClient, baseQueries, and ordersQueryUtils,
and deno task check is clean. Confirm the query module is in place:
deno check apps/dashboard/lib/api-clients.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 });
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.
Step 2 — Write the cache-first page loader
The page reads the cache, not the service. Add a small loader that pulls the orders island's initial
data from KV through the chapter-3 helpers. A cold cache returns undefined, which the page renders
as a skeleton:
// apps/dashboard/routes/(dashboard)/dashboard/orders/(_shared)/query-loaders.ts
import { baseQueries } from '@app/lib/api-clients.ts';
export async function ordersQueryLoader(ctx: { url: URL }) {
const input = {
limit: Number(ctx.url.searchParams.get('limit') ?? 20),
offset: Number(ctx.url.searchParams.get('offset') ?? 0),
};
const entry = await baseQueries.orders.list.getCachedEntry(input);
return {
initialOrders: entry?.data,
cachedAt: entry?.cachedAt,
input,
};
}
The loader returns three things the island needs: the cached data, the cachedAt timestamp (so
the client knows how stale the seed is), and the input (so the client can refetch the same slice).
Step 3 — Compose the page with definePage
definePage from @netscript/fresh/builders is a fluent builder. You bind the route, set a caching
policy, add layers, lay them out, and build(). Here is the orders page reduced to the live-table
spine:
// apps/dashboard/routes/(dashboard)/dashboard/orders/index.tsx
import { definePage } from '@netscript/fresh/builders';
import { routes } from '@app/router.ts';
import OrdersQueryIsland from './(_islands)/OrdersQueryIsland.tsx';
import { ordersQueryLoader } from './(_shared)/query-loaders.ts';
import { PlaygroundOrdersList, PlaygroundOrdersListSkeleton } from './(_components)/list.tsx';
import { baseQueries } from '@app/lib/api-clients.ts';
export const ordersListPage = definePage()
.withRoute(routes.dashboard.orders.$route)
.withPolicy('balanced')
.withTelemetry({ enabled: true, spanName: 'dashboard.orders.list' })
.withLayer('list', PlaygroundOrdersList, {
loader: async ({ url, search }) => {
const input = { limit: search.limit, offset: search.offset, status: search.status };
const cachedEntry = await baseQueries.orders.list.getCachedEntry(input);
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: ordersQueryLoader,
staleTime: 15_000,
staleReloadMode: 'background',
})
.withLayout((slots) => (
<main class='ns-page-end'>
<div class='ns-stack ns-stack--lg'>
{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. |
.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
The server shell paints from cache; the island makes it interactive. QueryIsland from
@netscript/fresh/query provides the TanStack Query context; inside it, useQuery reads through the
chapter-3 queryOptions, seeded with the loader's initialData so there is no client refetch flash.
useMutation advances an order's status optimistically:
// apps/dashboard/routes/(dashboard)/dashboard/orders/(_islands)/OrdersQueryIsland.tsx
import { QueryIsland, useMutation, useQuery, useQueryClient } from '@netscript/fresh/query';
import { ordersQueryUtils } from '@app/lib/api-clients.ts';
function OrdersQueryInner(props) {
const queryClient = useQueryClient();
const currentKey = ordersQueryUtils.list.clientKey(props.input);
// Server-seeded read: no client refetch flash on first paint.
const { data: orders, isRefetching } = useQuery({
...ordersQueryUtils.list.queryOptions(props.input),
initialData: props.initialOrders,
initialDataUpdatedAt: props.cachedAt,
staleTime: 15_000,
});
// Optimistic status advance — update the cache, roll back on error.
const statusMutation = useMutation({
...ordersQueryUtils.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);
},
onSettled: () =>
queryClient.invalidateQueries({ queryKey: ordersQueryUtils.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>
);
}
The key moves:
initialData+initialDataUpdatedAtseeduseQueryfrom the server loader, so the table is populated on first paint and TanStack treats it as fresh untilstaleTimeelapses.clientKey(input)is the same stable key from chapter 3 —useMutationreads, writes, and invalidates the cache through it, so the optimistic update lands on exactly the rowsuseQueryis showing.onMutate/onErrorare the optimistic pattern: apply the change immediately, snapshot the previous data, and roll back if the server rejects it.
Verify your progress
Make sure aspire start is up, then open the route in a browser:
http://localhost:8010/dashboard/orders/
(The Fresh app's port is :8010 in the Aspire stack; confirm the exact port in the
dashboard resource list.) 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,(_shared)/query-loaders.ts,index.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: real-time row updates over a durable StreamDB.