The storefront page
Every chapter so far built the backend: a typed catalog, a contract-first cart, a durable checkout, a
verified webhook. All of it is reachable by curl — and none of it has a face. This chapter puts one
on it, and in doing so closes the loop the whole track has been building toward: the same oRPC
contract that types the server handler also types the page's query and its checkout mutation. Write
a wrong field in the browser and it is a compile error, exactly as it is on the server. No second copy
of "what a cart is", no hand-written fetch wrapper, no DTO that drifts.
You will build a customer's cart page as a Fresh route with a typed route contract, then drive its catalog query and its checkout mutation through the SDK's contract-derived query utilities inside an island. This is the frontend half of NetScript's typed spine — the part the storefront backend never showed you until now.
- 1 · Scaffold
- 2 · Catalog service
- 3 · Cart contracts
- 4 · Checkout saga
- 5 · Shipping webhook
- 6 · Storefront UI
- 7 · Deploy
What you will build
A GET /cart/[customer] page. One bound route contract declares its URL pattern, its typed
{ customer } path param, and its typed pagination search — so a link to the page and the handler that
answers it can never disagree about its shape. A single clients module exposes a typed
productsClient and cartClient plus their query utilities, each derived from the chapter-2 and
chapter-3 contracts. An island then reads the catalog with useIslandQuery and begins checkout — a
new cart — with useIslandMutation, both keyed off those contract-derived helpers. The catalog query
runs live against the products service you have had up since chapter 2; the checkout mutation is
typed end to end against the cart contract from chapter 3.
Prerequisites
- The
productsservice on:3001(from chapter 2) and thecartcontract and its typed client (from chapter 3). aspire startup, so theproductsservice is discoverable by name (the dashboard answers at https://localhost:18888).- The route contract and query utilities are built into
@netscript/fresh/route,@netscript/sdk/client, and@netscript/sdk/query-client; none needs Aspire to type-check, though you run under Aspire to exercise the catalog query live.
Step 1 — Declare the bound route contract
Give the page a typed identity before any UI. A route contract from @netscript/fresh/route is the
single source of truth for a route's pattern, its path params, and its search params.
createRouteReference infers the { customer } param straight from the pattern; defineRouteContract
bindRoutePatternadd typed pagination search with safe defaults. Put it in the sharedcontracts/tree so the page and any link import the same object:
// contracts/routes/cart-page.ts
import {
bindRoutePattern,
createRouteReference,
defineRouteContract,
fallback,
paginationSearchSchema,
} from '@netscript/fresh/route';
import { z } from 'zod';
/** The one place the cart page pattern is written. */
export const CART_PAGE_PATTERN = '/cart/[customer]';
// createRouteReference infers the typed { customer } param from the pattern.
export const cartPageRef = createRouteReference(CART_PAGE_PATTERN);
/**
* The bound cart page: typed `{ customer }` path param + typed `limit`/`offset`
* pagination, all inferred from this one declaration.
*/
export const cartRoute = bindRoutePattern(
defineRouteContract({
pathSchema: z.object({ customer: z.string().min(1) }),
searchSchema: paginationSearchSchema({ defaultLimit: 12 }).extend({
// A junk ?highlight= falls back to undefined instead of throwing.
highlight: fallback(z.string().optional(), undefined),
}),
}),
CART_PAGE_PATTERN,
);
bindRoutePattern returns one object with everything downstream needs: cartRoute.parsePath(...) and
cartRoute.parseSearch(...) turn raw request params into typed values, and
cartRoute.href({ path: { customer } }) builds the URL for a link — so navigation and the handler
that answers it share one definition of the route.
| Name | Type | Description |
|---|---|---|
createRouteReference(pattern) |
RouteReference |
Infers typed path params directly from a Fresh route pattern — /cart/[customer] yields { customer: string }. |
defineRouteContract({ pathSchema?, searchSchema? }) |
DefineRouteContract |
Declares typed path and search schemas; bind it to one or more concrete patterns. |
bindRoutePattern(contract, pattern) |
BoundRouteContract |
Binds the contract to a pattern, returning one object with .parsePath / .parseSearch / .href. |
paginationSearchSchema(opts) / fallback(schema, default) |
search schema |
Typed limit/offset with computed defaults; fallback() catches junk query strings instead of 500-ing the page. |
Step 2 — One typed clients module
A client needs only a contract to be fully typed. Build one createServiceClient per service in a
single module — the "one place all clients live" pattern — and wrap each in
createServiceQueryUtils, which turns a client into per-procedure TanStack Query helpers:
// apps/storefront/lib/api-clients.ts
import { CartContractV1, ProductsContractV1 } from '@my-shop/contracts/versions/v1';
import { createServiceClient } from '@netscript/sdk/client';
import { createServiceQueryUtils } from '@netscript/sdk/query-client';
// One typed client per service. `serviceName` is the discovery key — how the
// client finds the service URL at call time; you never hardcode a port.
export const productsClient = createServiceClient<typeof ProductsContractV1>({
contract: ProductsContractV1,
serviceName: 'products',
});
export const cartClient = createServiceClient<typeof CartContractV1>({
contract: CartContractV1,
serviceName: 'cart',
});
// Contract-derived query/mutation utilities: one entry per procedure, each with
// a typed queryKey/queryOptions and mutationKey/mutationOptions.
export const productsQueries = createServiceQueryUtils(productsClient);
export const cartQueries = createServiceQueryUtils(cartClient);
productsClient.list(input) now has the exact signature ProductsContractV1.list declared, and
productsQueries.list.queryOptions({ input }) hands you the contract-derived cache key the island
reads and invalidates through. Change a field in the contract and this module re-type-checks — there
is no second definition of a product or a cart to keep in sync.
Step 3 — Read and mutate in the island
The island is where the typed contract meets the browser. useIslandQuery reads the catalog;
useIslandMutation begins checkout by creating a cart. Both come from @netscript/fresh/query — island
code imports query hooks from there, never from TanStack directly, so the dependency stays
centralized:
// apps/storefront/islands/CheckoutIsland.tsx
import {
QueryIsland,
useIslandMutation,
useIslandQuery,
useQueryClient,
} from '@netscript/fresh/query';
import {
cartClient,
cartQueries,
productsClient,
productsQueries,
} from '../lib/api-clients.ts';
interface CheckoutIslandProps {
customer: string;
input: { limit: number; offset: number };
initialProducts?: { items: Array<{ id: number; name: string }> };
}
function CheckoutInner({ customer, input, initialProducts }: CheckoutIslandProps) {
const queryClient = useQueryClient();
// READ — the catalog. The util gives the contract-derived cache key; the typed
// client makes the call. initialData seeds the first paint from a server loader.
const catalog = useIslandQuery({
queryKey: productsQueries.list.queryOptions({ input }).queryKey,
queryFn: () => productsClient.list(input),
initialData: initialProducts,
staleTime: 10_000,
});
// WRITE — begin checkout by creating the customer's cart, typed off CartContractV1.
// On success, invalidate the cart's query key so any cart view refetches.
const checkout = useIslandMutation({
mutationFn: (line: { productId: number; quantity: number }) =>
cartClient.create({ customerId: customer, items: [line] }),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: cartQueries.list.key({ type: 'query' }) }),
});
const products = catalog.data?.items ?? [];
return (
<ul class='ns-stack'>
{products.map((product) => (
<li key={product.id}>
<span>{product.name}</span>
<button
type='button'
disabled={checkout.isPending}
onClick={() => checkout.mutate({ productId: product.id, quantity: 1 })}
>
{checkout.isPending ? 'Adding…' : 'Add to cart'}
</button>
</li>
))}
</ul>
);
}
export default function CheckoutIsland(props: CheckoutIslandProps) {
return (
<QueryIsland>
<CheckoutInner {...props} />
</QueryIsland>
);
}
The moves that make this typed end to end:
queryKeyfrom the utility,queryFncalling the client.productsQueries.list.queryOptions(...)computes the same cache key the server uses, so a server-seeded row and a client-refetched row share one key. The island hook wants a zero-argumentqueryFn, so you invoke the typedproductsClient.list(input)there — the call is checked againstProductsContractV1, not a loosefetch.mutationFnis a typed contract call.cartClient.create({ customerId, items })is checked againstCartContractV1.create; pass an item without aproductIdand it failsdeno task check, not in production.- Invalidation is keyed off the contract too.
cartQueries.list.key({ type: 'query' })is the prefix-matchable cache key for every cart list query, so one line refetches the cart after checkout.
Step 4 — Wire the route to the page
The page reads its params through the contract and hands the island a typed slice. The route the
Fresh app registers (/cart/:customer) and the contract's pattern (/cart/[customer]) name the same
segment — cartRoute.parsePath bridges them:
// apps/storefront/routes/cart/[customer].tsx
import { cartRoute } from '../../../contracts/routes/cart-page.ts';
import CheckoutIsland from '../../islands/CheckoutIsland.tsx';
export default function CartPage(props: { params: { customer: string }; url: URL }) {
// Typed off the ONE route contract — no hand-parsing the URL.
const { customer } = cartRoute.parsePath(props.params);
const { limit, offset } = cartRoute.parseSearch(props.url.searchParams);
return (
<main class='ns-page'>
<h1>Cart for {customer}</h1>
<CheckoutIsland customer={customer} input= />
</main>
);
}
cartRoute.parseSearch applies the paginationSearchSchema defaults, so /cart/cust_1001 with no
query string still yields a typed { limit: 12, offset: 0 }. A link elsewhere builds the URL with
cartRoute.href({ path: { customer: 'cust_1001' } }) — the pattern is written once, in the contract.
Test it out
Type-check first — the whole typed chain is proven by the compiler, no server required:
# From the workspace root.
deno task check
A clean check means the route contract, both service clients, the query utilities, and the island all
line up with the chapter-2 and chapter-3 contracts. Now exercise the catalog query live. With
aspire start up and at least one product created (chapter 2), the products service answers by
discovery, so the page's query returns real rows:
# The query the island runs, against the live products service.
curl "http://localhost:3001/api/products?page=1&limit=12"
You get the page-shaped catalog back — the same items the island renders. Open the page in your
Fresh app (the app's port shows in the Aspire dashboard resource list) at /cart/cust_1001, and each
product carries an Add to cart button wired to the typed checkout mutation.
- [ ]
contracts/routes/cart-page.tsexportscartRoute, a bound route contract with a typed{ customer }param and pagination search. - [ ]
apps/storefront/lib/api-clients.tsexportsproductsClient,cartClient,productsQueries, andcartQueries, each derived from a contract. - [ ]
CheckoutIsland.tsxreads withuseIslandQueryand mutates withuseIslandMutation, keyed off the contract-derived helpers. - [ ] The page reads
customer,limit, andoffsetviacartRoute.parsePath/.parseSearch, not by hand. - [ ]
deno task checkpasses. - [ ]
curlagainst:3001/api/productsreturns the catalog the island renders.
What you built
- A
GET /cart/[customer]page whose route is a bound route contract: one object owns the pattern, the typed{ customer }path param, and the pagination search, and it produces both the page's typed params and the URL a link would call. - A single clients module with one typed
createServiceClientper service, each wrapped increateServiceQueryUtils— the contract-derived query and mutation helpers. - An island that reads the catalog with
useIslandQueryand begins checkout withuseIslandMutation, both keyed off those helpers, with cache invalidation keyed off the cart contract too.
That is the differentiator this chapter exists to prove: the oRPC contract you wrote once on the backend is the same type source for the URL, the query, and the mutation on the frontend — checked by the compiler from the database to the button, never a second hand-maintained copy that can drift.
Next Steps
- Ship it. Chapter 7 · Deploy runs the whole storefront —
services, plugins, and this page — under one
aspire start. - Go cache-first and live. The live-dashboard track takes the same query utilities further: KV-backed stale-while-revalidate reads and real-time streamed updates.
- Reference. The SDK reference and contracts explanation cover the full typed client-and-query surface.