---
title: "Add a working cart"
audience: "app developers"
description: "You'll build a cart whose contents live in the session on the server. Adding an item is one typed call from the browser. The server holds the only copy of…"
snippet_caption: "app/routes/cart/actions.ts"
snippet: "import { action, fail } from \"@snapfire/fsr/host\";\nimport type { ActionCtx } from \"@snapfire/fsr/host\";\nimport type { AddToCart, RemoveFromCart } from \"@schemas/cart\";\n\nexport const addToCart = action(async ({ input, session }: ActionCtx<AddToCart>) => {\n  const key = String(input.product_id);\n  const wanted = (session.cart[key] ?? 0n) + input.quantity;\n  if (wanted <= 0n) delete session.cart[key];\n  else session.cart = { ...session.cart, [key]: wanted };\n  const count = Object.values(session.cart).reduce((n, q) => n + q, 0n);\n  return { lines: session.cart, count };\n});\n\nexport const removeFromCart = action(async ({ input, session }: ActionCtx<RemoveFromCart>) => {\n  const key = String(input.product_id);\n  delete session.cart[key];\n  const count = Object.values(session.cart).reduce((n, q) => n + q, 0n);\n  return { lines: session.cart, count };\n});"
---

import { Install } from "@src/ui/Install";
import { Demo } from "@src/ui/Demo";
import Cart from "@src/demos/cart/Cart";

You'll build a cart whose contents live in the session on the server. Adding an item is one typed call from the browser. The server holds the only copy of the cart, so every page reads the same quantities from the session.

<Install captured="0.x" />

## Define the session

FSR reads a schema file at build time and generates the types that the server and the browser both use.

```ts title="app/schemas/session.ts"
export interface Session {
  cart: Record<string, bigint>;
}

export const defaults: Session = {
  cart: {},
};
```

Save that as `app/schemas/session.ts`. `defaults` sets what a visitor starts with, so the cart is always there when your code reads it.

## Define what the actions take

```ts title="app/schemas/cart.ts"
export interface AddToCart {
  product_id: bigint;
  quantity: bigint;
}

export interface RemoveFromCart {
  product_id: bigint;
}
```

That goes in `app/schemas/cart.ts`. `bigint` crosses the wire as a wide integer, so it keeps its precision past 2^53.

## Write the actions

An action is a function that runs on the server and is callable by id from the browser. Put these in `app/routes/cart/actions.ts` and they belong to the `/cart` route.

```ts title="app/routes/cart/actions.ts"
import { action, fail } from "@snapfire/fsr/host";
import type { ActionCtx } from "@snapfire/fsr/host";
import type { AddToCart, RemoveFromCart } from "@schemas/cart";

export const addToCart = action(async ({ input, session }: ActionCtx<AddToCart>) => {
  const key = String(input.product_id);
  const wanted = (session.cart[key] ?? 0n) + input.quantity;
  if (wanted <= 0n) delete session.cart[key];
  else session.cart = { ...session.cart, [key]: wanted };
  const count = Object.values(session.cart).reduce((n, q) => n + q, 0n);
  return { lines: session.cart, count };
});

export const removeFromCart = action(async ({ input, session }: ActionCtx<RemoveFromCart>) => {
  const key = String(input.product_id);
  delete session.cart[key];
  const count = Object.values(session.cart).reduce((n, q) => n + q, 0n);
  return { lines: session.cart, count };
});
```

`session` is the visitor's session, already loaded. Assigning to it persists it. `input` is typed as `AddToCart` from the schema. A request whose body doesn't match is refused before your code runs.

## Read it back in the loader

The loader joins the session's quantities against whatever your catalogue is.

```ts title="app/routes/cart/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";

export async function load({ session, services }: Ctx<"/cart">) {
  const catalog = await services.shopping.listProducts({});
  const lines = catalog
    .filter((p) => session.cart[String(p.id)])
    .map((p) => ({ ...p, quantity: session.cart[String(p.id)] }));
  return { lines };
}
```

`filter` and `map` lower, so this whole join runs in Rust. `services.shopping` is a typed client FSR generated from an OpenAPI document.

## Call it from the page

```tsx title="app/routes/cart/page.tsx"
import { actions, type CartProps } from "@generated/client";

export default function Cart({ lines }: CartProps) {
  const items = lines.reduce((n, l) => n + Number(l.quantity), 0);
  return (
    <section>
      <p>{items} in your cart</p>
      <ul>
        {lines.map((line) => (
          <li key={String(line.id)}>
            {line.name} × {String(line.quantity)}
            <button onClick={() => void actions.cart.addToCart({ product_id: line.id, quantity: 1n })}>+</button>
            <button onClick={() => void actions.cart.addToCart({ product_id: line.id, quantity: -1n })}>-</button>
            <button onClick={() => void actions.cart.removeFromCart({ product_id: line.id })}>Remove</button>
          </li>
        ))}
      </ul>
    </section>
  );
}
```

`actions.cart.addToCart` is generated. If you pass it the wrong shape, the build reports it before anything reaches the browser. A successful call revalidates the route, so the loader runs again and the list shows the new quantities.

<Demo title="The cart page from this tutorial, with an Add button for each product">
  <Cart catalog={props.demo.catalog} lines={props.demo.cart} />
</Demo>

This cart is in your session on this site's server. Add something, reload the page and it's still there.

## Add a guard

A guard runs before anything else the action would do. If it refuses, no service is called and nothing is written. Add a `checkout` action to the same file, below the other two:

```ts title="app/routes/cart/actions.ts"
export const checkout = action(async ({ session, services }: ActionCtx) => {
  const lines = Object.entries(session.cart).map(([id, quantity]) => ({ product_id: BigInt(id), quantity }));
  if (lines.length === 0) fail("invalid", "the cart is empty");
  const order = await services.shopping.placeOrder({ lines });
  session.cart = {};
  return order;
});
```

`fail("invalid", ...)` becomes an `ActionFailure` in the browser with a status the host chose from the kind. With an empty cart, the order service is never called.

Next up: [100. Cache loaders and renders](https://snapfirers.com/fsr/learn/100-cache-loaders-and-renders), where the pages that no longer change get answered before your code runs.
