Skip to main content
0.0.x

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. 1 · Scaffold
  2. 2 · Catalog service
  3. 3 · Cart contracts
  4. 4 · Checkout saga
  5. 5 · Shipping webhook
  6. 6 · Storefront UI
  7. 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 products service on :3001 (note: this tutorial pins the port to 3001; in unpinned scaffolds, each project is allocated its own randomized high-range ports) (from chapter 2) and the cart contract and its typed client (from chapter 3).
  • aspire start up, so the products service 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

  • bindRoutePattern add typed pagination search with safe defaults. Put it in the shared contracts/ 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.

@netscript/fresh/route — the bound route contract surface
NameTypeDescription
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 module per service

A client needs only a contract to be fully typed. Keep each service's client and cache-aware query factory together in lib/<service>.ts, matching service add <name> --with-client:

// apps/storefront/lib/products.ts
import { ProductsContractV1 } from '@my-shop/contracts/versions/v1';
import { createServiceClient } from '@netscript/sdk/client';
import { createQueryFactories } from '@netscript/sdk/query';

// 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 productsQueries = createQueryFactories({
  products: { contract: ProductsContractV1, client: productsClient },
}).products;

// apps/storefront/lib/cart.ts follows the same generated shape:
// cartClient + createQueryFactories({ cart: ... }).cart → cartQueries.

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 } from '../lib/cart.ts';
import { productsClient, productsQueries } from '../lib/products.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.clientKey() }),
  });

  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:

  • queryKey from the utility, queryFn calling 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-argument queryFn, so you invoke the typed productsClient.list(input) there — the call is checked against ProductsContractV1, not a loose fetch.
  • mutationFn is a typed contract call. cartClient.create({ customerId, items }) is checked against CartContractV1.create; pass an item without a productId and it fails deno task check, not in production.
  • Invalidation is keyed off the contract too. cartQueries.list.clientKey() is the prefix-matchable cache key for every cart list query, so one line refetches the cart after checkout.

Adding to a cart is a widget event, which is why it lives in the island. A checkout that collects an address and then navigates is a page event, and that is the other half of the decision — definePage().withForm() owns the validation, the error round trip, and the CSRF token so the page still works without JavaScript. Server-validated forms covers the split and the pipeline behind it.

Step 4 — Wire the route to the page

The page is built using NetScript's fluent definePage page builder. By binding to the cartRoute contract, the page gains access to type-safe path and search schemas so ctx.path and ctx.search are fully typed throughout the pipeline. .withResource() then registers a value resolved once per request — see Request-scoped resources for what "once" guarantees:

// apps/storefront/routes/cart/[customer].tsx
import { definePage } from '@app/utils.ts';
import { cartRoute } from '../../../contracts/routes/cart-page.ts';
import CheckoutIsland from '../../islands/CheckoutIsland.tsx';
import { productsClient } from '../../lib/products.ts';

const cartPage = definePage()
  .withRoute(cartRoute)
  .withResource('initialProducts', async (ctx) => {
    // Resolve the first page of products on the server side
    const input = { limit: ctx.search.limit, offset: ctx.search.offset };
    return await productsClient.list(input);
  })
  .withLayer('checkout', CheckoutIsland, {
    loader: async (ctx) => ({
      customer: ctx.path.customer,
      input: { limit: ctx.search.limit, offset: ctx.search.offset },
      initialProducts: await ctx.resource('initialProducts'),
    }),
  })
  .withLayout((slots, ctx) => (
    <main class='ns-page'>
      <h1>Cart for {ctx.path.customer}</h1>
      {slots.checkout()}
    </main>
  ))
  .build();

export default cartPage.default;

definePage is imported from @app/utils.ts — the module chapter 1's scaffold wrote, which re-exports the builder bound to your app's State type so every page shares one typed context.

.withRoute(cartRoute) hands the contract's schemas to the builder, so the parsing you would otherwise write by hand happens before your loader runs: ctx.path.customer is a typed string and ctx.search carries the paginationSearchSchema defaults, so /cart/cust_1001 with no query string still yields { 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, and both the page and the link read it from there.

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.ts exports cartRoute, a bound route contract with a typed { customer } param and pagination search.
  • [ ] apps/storefront/lib/products.ts and lib/cart.ts export their service-derived clients and query factories.
  • [ ] CheckoutIsland.tsx reads with useIslandQuery and mutates with useIslandMutation, keyed off the contract-derived helpers.
  • [ ] The page binds to the contract and loads initial products using definePage and a layer loader.
  • [ ] deno task check passes.
  • [ ] curl against :3001/api/products returns 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 context and the URL a link would call. definePage binds that contract, resolves the first page of products on the server, and wires the checkout island as a layer.
  • One module per service with a typed createServiceClient wrapped in createQueryFactories — the contract-derived query and mutation helpers shared by loaders and islands.
  • An island that reads the catalog with useIslandQuery and begins checkout with useIslandMutation, 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.