---
title: Storefront
description: Render brands on the Next.js storefront in apps/storefront — call the Store API through @spree/sdk and build the product and brand pages.
---

The Store API endpoint from step 1 becomes typed TypeScript and a component on the product page. This step closes the loop from the Server app to the customer’s screen.

## Step 1: Describe the resource to TypeScript

The storefront lives at `apps/storefront/` and talks to the Store API through `@spree/sdk`. Built-in resources like products and carts already ship typed. A resource you added is new to the SDK, so declare its shape once and reuse it:

```typescript apps/storefront/src/types/brand.ts
export interface Brand {
  id: string
  name: string
  slug: string | null
  active: boolean
}
```

These are exactly the fields your Store serializer returns — the public ones, without the timestamps the Admin API adds. Keep this file in step with the serializer: it is the contract between the two halves of your project.

> **NOTE:** Spree generates the built-in SDK types from its own serializers, which is why `client.products` is typed without you doing anything. That pipeline runs inside the Spree repository, not your app, so a resource you add gets a hand-written interface like the one above.

## Step 2: Call the endpoint

`createClient` gives you a typed Store API client. Built-in resources have methods; custom ones go through `request`, which carries the same authentication, retries and locale defaults:

```typescript apps/storefront/src/lib/spree.ts
import { createClient } from '@spree/sdk'

export const client = createClient({
  baseUrl: process.env.NEXT_PUBLIC_SPREE_API_URL!,
  publishableKey: process.env.NEXT_PUBLIC_SPREE_PUBLISHABLE_KEY!,
})
```

Fetching brands then goes through `request`, with paths relative to `/api/v3/store`:

```typescript
import type { PaginatedResponse } from '@spree/sdk'
import { client } from '@/lib/spree'
import type { Brand } from '@/types/brand'

const brands = await client.request<PaginatedResponse<Brand>>('GET', '/brands')
const wilson = await client.request<Brand>('GET', '/brands/brand_k5nR8xLq')
```

Only the fields the Store serializer declared come back. Timestamps and anything admin-only are absent by design — that is the serializer split from step 1 doing its job.

> **NOTE:** A generated resource is addressed by its prefixed ID. Looking one up by slug needs a `find_resource` override on the controller — the products endpoint does exactly that, so `/products/{slug}` works. Until you add one for brands, route by ID and treat `slug` as display data.

## Step 3: Expand brand on products

Because `Spree::Product` now belongs to a brand, product responses can carry it. Ask for it with `expand`:

```typescript
// Without expand — the id only
const product = await client.products.get('prod_86Rf07xd4z')

// With expand — the nested brand object
const withBrand = await client.products.get('prod_86Rf07xd4z', {
  expand: ['brand'],
})
```

Expanding avoids a second round trip per product, which matters on a product listing page.

> **INFO:** For `expand: ['brand']` to work, the Product serializer must declare the association and the model must allowlist it for filtering. Add `brand` to the product's `whitelisted_ransackable_associations` if you also want `?q[brand_name_cont]=wilson`. Every allowlist entry is a query any caller can run, so add only what the storefront needs.

## Step 4: Render it on the product page

In the Next.js storefront, fetch the product with its brand and render the name:

```tsx apps/storefront/src/app/products/[slug]/page.tsx
import { client } from '@/lib/spree'

export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const product = await client.products.get(slug, { expand: ['brand'] })

  return (
    <article>
      {product.brand && (
        <a href={`/brands/${product.brand.id}`} className="text-sm text-neutral-500">
          {product.brand.name}
        </a>
      )}
      <h1>{product.name}</h1>
      <p>{product.price.display}</p>
    </article>
  )
}
```

And a brand page listing everything that brand makes:

```tsx apps/storefront/src/app/brands/[id]/page.tsx
import { client } from '@/lib/spree'
import type { Brand } from '@/types/brand'

export default async function BrandPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const brand = await client.request<Brand>('GET', `/brands/${id}`)
  const products = await client.products.list({ brand_id_eq: brand.id })

  return (
    <section>
      <h1>{brand.name}</h1>
      <p>{products.meta.count} products</p>
      <ul>
        {products.data.map((product) => (
          <li key={product.id}>
            <a href={`/products/${product.slug}`}>{product.name}</a>
          </li>
        ))}
      </ul>
    </section>
  )
}
```

`brand_id_eq` is a Ransack predicate, available because `brand_id` is on the product's ransackable allowlist. See [search and filtering](../core-concepts/search-filtering.md) for the full predicate list.

> **NOTE:** The storefront runs on its own dev server. From the project root, `spree dev` starts the API and dashboard; start the storefront alongside them with `cd apps/storefront && pnpm dev`. It reads the API URL and publishable key from `apps/storefront/.env.local`.

## Next step

Customers can see brands. Now let other systems react when they change: [Events](events.md).
