Building an API Client

Building an API Client

Welcome back. In the previous lesson, you built the storefront shell: the global layout, the shared navigation and footer, the reusable layout container, and the first customer-facing page structure. At that stage, the app already looked like a real storefront, but most of its content was still static. The shell was ready — now it needs real data.

This lesson is where the storefront starts speaking to the backend. You will define the shared frontend types that describe the data flowing through the app, build a reusable API client so pages do not have to deal with raw fetch details, and introduce the first product-loading hook that owns the async lifecycle for catalog data. Then you will connect that data layer to reusable product UI components and use it in both the home page and the shop page.

A major idea in this lesson is that data access should be structured, typed, and reusable. Pages should not manually parse server responses, guess at error shapes, or repeat loading logic. Instead, shared types should describe the contract, API helpers should own transport details, hooks should own async state, and page components should focus on orchestration and rendering. That separation is what makes a growing frontend codebase manageable.

Previously: Building the Storefront Shell

In the previous lesson, you established the app’s visual foundation through files like src/app/layout.tsx, src/components/layout/AppShell.tsx, src/components/layout/Navbar.tsx, src/components/layout/Footer.tsx, and src/components/layout/PageContainer.tsx. You also introduced reusable presentation primitives such as Button, SectionHeading, EmptyState, LoadingState, ErrorState, and StatusBadge, then used them to compose src/components/home/HomePageClient.tsx and the placeholder shop route.

That work matters directly here. Because the shell and shared UI already exist, this lesson can focus on adding real product data without first solving layout problems. The home page and shop page will now start consuming the same shared product-loading mechanism, which is exactly the kind of reuse the shell was designed to support.

Defining the Frontend Domain Model

Before the frontend can request and render real data, it needs a clear typed model of what that data looks like. The file src/lib/types/domain.ts defines the core domain entities used throughout the storefront. Even though this lesson focuses mainly on products, the file also includes related models that later lessons will use for carts, orders, and tax rates.

Here is the full file:

export type Currency = 'USD';

export interface Product {
  id: string;
  sku: string;
  name: string;
  description: string | null;
  price_cents: number;
  currency: Currency;
  inventory_count: number;
  status: 'active' | 'archived';
  created_at: string;
  updated_at: string;
}

export interface CartItem {
  id: string;
  cart_id: string;
  product_id: string;
  quantity: number;
  unit_price_cents: number;
  created_at: string;
  updated_at: string;
  product?: Pick<Product, 'id' | 'sku' | 'name' | 'price_cents' | 'currency' | 'status'>;
}

export interface CartTotals {
  subtotal_cents: number;
  tax_cents: number;
  total_cents: number;
  currency: Currency;
  tax_rate_bps: number;
  tax_country: string | null;
}

export interface Cart {
  id: string;
  status: 'open' | 'checked_out' | 'abandoned';
  tax_country?: string | null;
  created_at: string;
  updated_at: string;
  items?: CartItem[];
  totals?: CartTotals;
}

export interface OrderItem {
  id: string;
  order_id: string;
  product_id: string;
  quantity: number;
  unit_price_cents: number;
  created_at: string;
  updated_at: string;
}

export interface Order {
  id: string;
  cart_id: string;
  status: 'pending' | 'paid' | 'shipped' | 'cancelled';
  subtotal_cents: number;
  tax_cents: number;
  total_cents: number;
  currency: Currency;
  tax_country: string | null;
  tax_rate_bps: number;
  created_at: string;
  updated_at: string;
  items?: OrderItem[];
}

export interface TaxRate {
  country_code: string;
  rate_bps: number;
  created_at: string;
  updated_at: string;
}
  • Currency is currently a narrow union type containing only 'USD'. Even though it is small right now, it is still valuable because it gives the rest of the app a shared typed concept for currency instead of using loose strings everywhere.

  • Product is the most important model in this lesson because product lists, featured product sections, and later detail pages all depend on it. Notice that it includes not just display fields like name and price_cents, but also inventory, status, and timestamps, which means the frontend is being prepared to render richer catalog information than just a title and price.

  • description is typed as string | null, which is an important modeling decision. That tells the UI that a description might be missing, so components must handle that case instead of assuming text is always present.

  • CartItem includes both raw cart item fields and an optional embedded product summary. This is a good example of a frontend domain type matching the kind of joined or nested data a backend may return for convenience.

  • Pick<Product, ...> is used to keep the optional nested product shape focused. Instead of embedding the entire Product model in every cart item, the type communicates that only a specific subset of product fields may be present there.

  • CartTotals, Cart, OrderItem, Order, and TaxRate are not the main focus of this lesson, but defining them now stabilizes the shared type layer. That gives the codebase better autocomplete, cleaner imports, and less type churn later when carts and orders are introduced.

  • Good shared domain types improve the developer experience across the whole app. Hooks, UI components, API helpers, and formatting utilities can all agree on the same shapes, which makes refactoring safer and reduces the chance of mismatched assumptions between files.

Modeling API Envelopes Explicitly

The frontend is not just consuming raw records like Product[]. It is communicating with a typed backend contract that wraps data in success and error envelopes. The file src/lib/types/api.ts defines those shared response shapes.

export type ApiErrorCode =
  | 'VALIDATION_ERROR'
  | 'NOT_FOUND'
  | 'CONFLICT'
  | 'DB_ERROR'
  | 'INTERNAL_ERROR';

export interface ApiSuccess<T> {
  data: T;
  meta?: { timestamp: string };
}

export interface ApiError {
  error: { code: ApiErrorCode; message: string; details?: unknown };
  meta?: { timestamp: string };
}

export type ApiResponse<T> = ApiSuccess<T> | ApiError;
  • ApiErrorCode defines the known set of backend error categories. This is useful because the frontend can reason about specific failure types in a structured way instead of only dealing with vague message strings.

  • ApiSuccess<T> wraps successful payloads in a data field, with optional metadata alongside it. The generic <T> means the same success envelope shape can hold many different payloads, such as a single Product, a Product[], or later a cart or order response.

  • ApiError models failed responses as an error object with a code, message, and optional details. This makes failure handling much more predictable than checking arbitrary JSON shapes.

  • ApiResponse<T> is a union type representing either a success envelope or an error envelope. This is the backbone of the API client because it tells TypeScript that every parsed response must be interpreted as one of these two cases.

This typed contract is what allows the frontend to parse server responses confidently rather than treating everything as unstructured JSON.

Defining Request Payload DTOs

In addition to response types, the frontend also needs shared request payload shapes for operations it may send to the backend. The file src/lib/types/dto.ts defines those data transfer objects.

import { Currency } from './domain';

export interface CreateProductInput {
  sku: string;
  name: string;
  description?: string | null;
  price_cents: number;
  currency?: Currency;
  inventory_count: number;
}

export interface UpdateProductInput {
  name?: string;
  description?: string | null;
  price_cents?: number;
  currency?: Currency;
  inventory_count?: number;
  status?: 'active' | 'archived';
}

export interface AddCartItemInput {
  product_id: string;
  quantity: number;
}

export interface UpdateCartItemInput {
  quantity: number;
}
  • CreateProductInput describes the payload shape for creating a product. Even though product creation is not the main UI flow in this lesson, defining the type now helps the API layer stay complete and consistent.

  • Notice that some fields are optional, such as description and currency. That tells the frontend which values may be omitted when constructing requests, while still giving strong typing around the fields that do matter.

  • UpdateProductInput uses optional properties throughout because updates are often partial. In other words, a PATCH-style request may change only a subset of fields rather than resending the entire resource.

  • AddCartItemInput and UpdateCartItemInput anticipate later lessons involving cart mutations. Defining them early helps establish a stable shared types layer, so later hooks and API modules can build on the same contract instead of inventing request shapes ad hoc.

These DTO types may not all be used immediately in the visible UI, but they improve the overall data architecture by making request contracts explicit from the start.

Creating Clean Type Entry Points

Once types are defined, the next step is making them easy to import. The barrel files src/types/domain/index.ts and src/types/api/index.ts expose clean entry points so other files do not need to reach deep into the folder structure.

Here is src/types/domain/index.ts:

export type {
  Cart,
  CartItem,
  CartTotals,
  Currency,
  Order,
  OrderItem,
  Product,
  TaxRate,
} from '@/lib/types/domain';
  • This file re-exports the domain types from a single stable path. That means consumers can import from @/types/domain instead of remembering the full underlying source path.

  • Barrel files improve developer experience in a subtle but important way. They make imports easier to write, easier to discover, and easier to refactor later if the internal folder structure changes.

Here is src/types/api/index.ts:

export type { ApiError, ApiErrorCode, ApiResponse, ApiSuccess } from '@/lib/types/api';
export type { AddCartItemInput, CreateProductInput, UpdateCartItemInput, UpdateProductInput } from '@/lib/types/dto';

export interface UpdateTaxRateInput {
  rate_bps: number;
}
  • This barrel re-exports both API envelope types and DTO request types from a single place. That gives API modules a convenient shared import surface.

  • UpdateTaxRateInput is defined here as a small additional shared type for later tax-rate operations. Even though it is not used in this lesson’s visible product flow, keeping it in the shared API types area helps the overall contract layer stay organized.

Good barrel files are not about cleverness. They are about making the codebase easier to navigate and safer to scale.

Building a Reusable API Client

Now that the shared types exist, the frontend needs a low-level utility that knows how to communicate with the backend consistently. The file src/lib/api/client.ts provides that foundation. Its job is to own transport concerns such as calling fetch, parsing JSON, understanding the API envelope, and converting failures into a consistent error shape.

Here is the complete file:

import { ApiError, ApiResponse } from '@/types/api';

export class ApiClientError extends Error {
  code: ApiError['error']['code'];
  details?: unknown;
  status: number;

  constructor(params: { message: string; code: ApiError['error']['code']; status: number; details?: unknown }) {
    super(params.message);
    this.name = 'ApiClientError';
    this.code = params.code;
    this.status = params.status;
    this.details = params.details;
  }
}

function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null;
}

function isApiErrorEnvelope(value: unknown): value is ApiError {
  if (!isObject(value) || !('error' in value)) return false;
  const error = value.error;
  return isObject(error) && typeof error.code === 'string' && typeof error.message === 'string';
}

function isApiSuccessEnvelope<T>(value: unknown): value is { data: T } {
  return isObject(value) && 'data' in value;
}

function buildHeaders(init?: RequestInit) {
  const headers = new Headers(init?.headers);
  if (!headers.has('Content-Type') && init?.body) {
    headers.set('Content-Type', 'application/json');
  }
  return headers;
}

export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(path, {
    ...init,
    headers: buildHeaders(init),
  });

  let parsed: unknown;
  try {
    parsed = (await response.json()) as ApiResponse<T>;
  } catch {
    throw new ApiClientError({
      message: 'The server returned an invalid JSON response.',
      code: 'INTERNAL_ERROR',
      status: response.status,
    });
  }

  if (isApiErrorEnvelope(parsed)) {
    throw new ApiClientError({
      message: parsed.error.message,
      code: parsed.error.code,
      status: response.status,
      details: parsed.error.details,
    });
  }

  if (!response.ok) {
    throw new ApiClientError({
      message: 'The request failed.',
      code: 'INTERNAL_ERROR',
      status: response.status,
    });
  }

  if (!isApiSuccessEnvelope<T>(parsed)) {
    throw new ApiClientError({
      message: 'The server returned an unexpected response shape.',
      code: 'INTERNAL_ERROR',
      status: response.status,
    });
  }

  return parsed.data;
}

export function toRequestInit(method: string, body?: unknown): RequestInit {
  if (body === undefined) return { method };
  return { method, body: JSON.stringify(body) };
}

export function getErrorMessage(error: unknown) {
  if (error instanceof ApiClientError) return error.message;
  if (error instanceof Error) return error.message;
  return 'Something went wrong. Please try again.';
}
  • ApiClientError extends the native Error class so the API layer can throw a richer, more structured failure object. Instead of only carrying a message, it also carries the backend error code, HTTP status, and optional details, which makes failures easier to reason about in hooks and UI.

  • The code property is typed as ApiError['error']['code'], which means it stays aligned with the shared API error contract instead of drifting into an untyped string. This is a nice example of letting shared types strengthen multiple layers of the application.

  • isObject, isApiErrorEnvelope, and isApiSuccessEnvelope are type guards that help the client validate the shape of parsed JSON. This matters because response.json() gives you unknown runtime data, and the frontend should not blindly assume the server always returned the correct structure.

  • buildHeaders() centralizes header handling and adds Content-Type: application/json only when needed. That small helper keeps request construction cleaner and avoids repeating header logic in every API module.

  • apiRequest<T>() is generic, which means callers can specify the expected payload type and receive properly typed data back. This is the core API client abstraction: pages and hooks can ask for Product[] or Product without manually parsing envelopes or casting JSON themselves.

  • The fetch call merges the caller’s RequestInit with normalized headers from buildHeaders(). This keeps the transport logic in one place, which is exactly what a reusable API client should do.

  • The try/catch around response.json() protects the frontend from invalid JSON responses. That is an important edge case because even a successful network response is not useful if the body cannot be parsed into usable data.

  • The client checks for an explicit API error envelope before checking response.ok. That order matters because the backend may return a meaningful structured error payload, and the client should preserve that detail rather than replacing it with a generic failure message too early.

  • The additional checks for !response.ok and an unexpected success shape provide a consistent fallback when the backend response is malformed or incomplete. In other words, the client is not only protecting against known error envelopes, but also against broken or surprising response shapes.

  • toRequestInit() is a small helper for building request options for POST, PATCH, and DELETE calls. It keeps request construction concise and consistently JSON-encodes bodies when a payload is present.

  • getErrorMessage() is a UI-friendly helper that extracts a readable message from either an ApiClientError, a normal Error, or an unknown thrown value. This is especially useful in hooks, where the UI often only needs a clean user-facing message instead of the full error object.

A good API client is not supposed to be flashy. Its value is that other parts of the app stop needing to worry about ugly request details.

Creating Product-Specific API Helpers

With the low-level API client in place, the next layer is a resource-specific API module. The file src/lib/api/products.ts contains helpers for product-related requests. This is where the rest of the app should go when it wants product data, instead of constructing raw fetch calls manually.

import { apiRequest, toRequestInit } from '@/lib/api/client';
import { CreateProductInput, UpdateProductInput } from '@/types/api';
import { Product } from '@/types/domain';

function buildProductsPath(query?: string) {
  if (!query || !query.trim()) return '/api/products';
  return `/api/products?query=${encodeURIComponent(query.trim())}`;
}

export function listProducts(query?: string) {
  return apiRequest<Product[]>(buildProductsPath(query));
}

export function getProduct(id: string) {
  return apiRequest<Product>(`/api/products/${id}`);
}

export function createProduct(input: CreateProductInput) {
  return apiRequest<Product>('/api/products', toRequestInit('POST', input));
}

export function updateProduct(id: string, input: UpdateProductInput) {
  return apiRequest<Product>(`/api/products/${id}`, toRequestInit('PATCH', input));
}

export function archiveProduct(id: string) {
  return apiRequest<Product>(`/api/products/${id}`, toRequestInit('DELETE'));
}
  • This file sits at a nice middle layer in the data architecture. It is higher-level than apiRequest<T>(), because it knows about products specifically, but lower-level than hooks and pages, because it is still only concerned with making requests and returning typed data.

  • buildProductsPath() is a small but useful helper that centralizes query-string handling. It trims user input, avoids sending an empty search parameter, and safely encodes the value when a query is present.

  • listProducts(query?) returns apiRequest<Product[]>, which means any consumer calling it gets a properly typed list of products without dealing with envelopes or path construction. This is exactly the kind of clean API surface you want other parts of the app to depend on.

  • getProduct(id) handles the single-product read case and returns a typed Product. Later product detail screens can use this helper instead of embedding route-specific request code.

  • createProduct, updateProduct, and archiveProduct are not the visible focus of this lesson, but defining them now creates a more complete and stable product API module. That makes the module a real home for product-related transport concerns rather than a temporary list-only helper.

  • By routing all product requests through this module, the rest of the app gets cleaner and more maintainable. Hooks and components can think in terms of product operations, not in terms of URL strings and HTTP setup.

Owning Product Loading with a Shared Hook

The next layer up is the first real data hook: src/lib/hooks/useProducts.ts. This hook owns the async lifecycle for product collections, including the current products, loading state, error state, and a refresh() function. This is a key step in moving data logic out of page components.

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { getErrorMessage } from '@/lib/api/client';
import { listProducts } from '@/lib/api/products';
import { Product } from '@/types/domain';

export function useProducts(query?: string) {
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const requestIdRef = useRef(0);

  const refresh = useCallback(async () => {
    const requestId = requestIdRef.current + 1;
    requestIdRef.current = requestId;
    setIsLoading(true);

    try {
      const response = await listProducts(query);
      if (requestId !== requestIdRef.current) return [];
      setProducts(response);
      setErrorMessage(null);
      return response;
    } catch (error) {
      if (requestId !== requestIdRef.current) return [];
      setProducts([]);
      setErrorMessage(getErrorMessage(error));
      return [];
    } finally {
      if (requestId === requestIdRef.current) {
        setIsLoading(false);
      }
    }
  }, [query]);

  useEffect(() => {
    void refresh();
  }, [refresh]);

  return { products, isLoading, errorMessage, refresh };
}
  • The 'use client' directive is necessary because this hook uses React state, refs, effects, and callbacks. This is a client-side data hook, so it belongs in the client component world.

  • products, isLoading, and errorMessage represent the core async state for a product collection. This is the basic UI contract the rest of the app will use to decide what to render: data, loading UI, or an error state.

  • requestIdRef is a very important detail. It acts as a guard against stale overlapping requests, which can happen when a user types quickly and multiple searches are in flight at once.

  • Inside refresh(), a new request id is generated and stored before the request begins. That means every fetch attempt gets a unique sequence number representing the most recent intended request.

  • After listProducts(query) resolves, the hook checks whether the completed request is still the latest one. If not, it exits early and ignores the old response. This prevents an older slower response from overwriting newer search results, which is exactly the stale request problem the guard is designed to solve.

  • The same stale-request guard appears in the catch and finally blocks as well. That consistency matters because not only successful results but also errors and loading completion state can become stale when multiple requests overlap.

  • getErrorMessage(error) converts whatever was thrown into a clean user-facing string. That keeps page components simpler because they do not need to inspect error objects themselves.

  • useCallback() memoizes refresh() based on query, and useEffect() calls it whenever the memoized function changes. In practice, that means the hook automatically fetches products on mount and refetches whenever the incoming query changes.

  • Returning refresh alongside the state is a great example of a hook exposing a useful interface instead of only raw data. Later components can choose to trigger a refetch explicitly, such as after a mutation or retry action.

This hook is doing exactly what a good shared data hook should do: it centralizes async state management so page components can stay focused on orchestration and rendering.

Creating a Reusable Search Input

The shop page needs a search input, but the input itself should not own fetch logic. The file src/components/products/SearchBar.tsx keeps the search UI reusable and focused on presentation and events.

export function SearchBar({
  value,
  onChange,
  placeholder = 'Search by name or SKU',
}: {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
}) {
  return (
    <label className="block">
      <span className="sr-only">Search products</span>
      <input
        type="search"
        value={value}
        onChange={(event) => onChange(event.target.value)}
        placeholder={placeholder}
        className="w-full rounded-full border border-stone-200 bg-white px-5 py-3 text-sm text-stone-900 shadow-sm outline-none transition focus:border-stone-400"
      />
    </label>
  );
}
  • The component exposes a controlled input interface through value and onChange. That means the parent owns the actual search state, while the search bar remains a reusable UI building block.

  • This separation is important because the search bar’s job is only to collect text input and report changes. It should not decide when to fetch, how to debounce, or how to talk to the API.

  • The placeholder prop has a default value but can still be customized later. That makes the component flexible without complicating its public API.

  • The visually hidden <span> provides an accessible label for screen readers. Even simple reusable inputs should carry accessibility support with them so pages do not have to retrofit it later.

Rendering Individual Products with ProductCard

Once product data is loaded, the storefront needs a reusable way to present a single product. The file src/components/products/ProductCard.tsx is responsible for that display.

import { StatusBadge } from '@/components/ui/StatusBadge';
import { formatMoney, getInventoryLabel } from '@/lib/utils/format';
import { Product } from '@/types/domain';

export function ProductCard({ product }: { product: Product }) {
  return (
    <article className="flex h-full flex-col rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm">
      <div className="flex items-start justify-between gap-4">
        <div>
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">
            {product.sku}
          </p>
          <h3 className="mt-2 font-serif text-2xl text-stone-950">
            {product.name}
          </h3>
        </div>
        <StatusBadge status={product.status} />
      </div>
      <p className="mt-4 flex-1 text-sm leading-6 text-stone-600">
        {product.description?.trim() ||
          'A versatile everyday pick with clear pricing and current availability.'}
      </p>
      <div className="mt-6 flex items-end justify-between gap-4">
        <div>
          <p className="text-2xl font-semibold text-stone-950">
            {formatMoney(product.price_cents, product.currency)}
          </p>
          <p className="mt-1 text-sm text-stone-500">
            {getInventoryLabel(product)}
          </p>
        </div>
      </div>
    </article>
  );
}
  • ProductCard depends on the shared Product type, which means the component knows exactly what data shape it expects. This is a direct payoff from the shared domain model defined earlier.

  • The card uses StatusBadge for product status instead of inventing its own status styling. That keeps product presentation aligned with the rest of the shared UI system.

  • formatMoney() and getInventoryLabel() keep presentation logic out of the component body. That makes the JSX easier to read and ensures formatting rules stay consistent across multiple screens.

  • The description area includes a fallback message when the product description is empty or whitespace-only. That is an important detail because the domain model already told us description may be missing, so the UI should handle that case gracefully.

  • Structurally, this component is display-only. It does not fetch data, manage state, or understand search logic. Its responsibility is simply to present one product clearly and consistently.

Rendering Product Collections with ProductGrid

A product list should not repeat card layout logic in every page. The file src/components/products/ProductGrid.tsx provides a reusable grid wrapper for a collection of products.

import { ProductCard } from '@/components/products/ProductCard';
import { Product } from '@/types/domain';

export function ProductGrid({ products }: { products: Product[] }) {
  return (
    <div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}
  • ProductGrid accepts an array of Product values and maps each one to a ProductCard. This keeps collection rendering consistent across different pages while still delegating individual item presentation to the card component.

  • The key={product.id} prop is important in React because it helps React track list items efficiently across renders. Using the stable product id is the correct choice here.

  • The responsive grid classes create a layout that scales from one column up to multiple columns as space increases. That means both the home page featured section and the full shop page can reuse the same component and still look appropriate.

This component is a good example of composition: ProductGrid handles collection layout, ProductCard handles single-item presentation.

Keeping the Shop Route Thin

Just like in the previous lesson, the route file for the shop page should stay small. The file src/app/shop/page.tsx does not own client-side search or fetching logic. Its job is simply to hand off rendering to a dedicated client component.

import { ShopPageClient } from '@/components/shop/ShopPageClient';

export default function ShopPage() {
  return <ShopPageClient />;
}
  • This route stays intentionally thin, which is a strong architectural choice. The app router file identifies which page component should render for /shop, but it does not become the place where search state, loading logic, and UI branching all live.

  • Keeping route files small makes them easier to scan and easier to maintain. As the app grows, this pattern helps keep responsibilities separated between routing concerns and page composition concerns.

Orchestrating Search and Async UI in ShopPageClient

The real shop page logic lives in src/components/shop/ShopPageClient.tsx. This component is responsible for managing the search input, deriving a deferred version of the query, calling useProducts(), and deciding which UI state to show: loading, error, empty, or product results.

'use client';

import Link from 'next/link';
import { useDeferredValue, useState } from 'react';
import { PageContainer } from '@/components/layout/PageContainer';
import { ProductGrid } from '@/components/products/ProductGrid';
import { SearchBar } from '@/components/products/SearchBar';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { ErrorState } from '@/components/ui/ErrorState';
import { LoadingState } from '@/components/ui/LoadingState';
import { SectionHeading } from '@/components/ui/SectionHeading';
import { useProducts } from '@/lib/hooks/useProducts';

export function ShopPageClient() {
  const [searchValue, setSearchValue] = useState('');
  const deferredQuery = useDeferredValue(searchValue);
  const { products, isLoading, errorMessage } = useProducts(
    deferredQuery.trim() || undefined,
  );

  return (
    <PageContainer className="space-y-8 py-12 md:py-16">
      <SectionHeading
        eyebrow="Catalog"
        title="Search the active inventory"
        description="Search by product name or SKU to find what you need. `useDeferredValue()` keeps typing responsive, but every change still flows through the products hook."
      />
      <div className="grid gap-6 rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm lg:grid-cols-[1.2fr_0.8fr] lg:items-center">
        <SearchBar value={searchValue} onChange={setSearchValue} />
        <p className="text-sm leading-6 text-stone-500">
          Start typing to narrow the catalog and compare current availability at
          a glance.
        </p>
      </div>

      {isLoading ? <LoadingState message="Loading catalog..." /> : null}
      {!isLoading && errorMessage ? (
        <ErrorState message={errorMessage} />
      ) : null}
      {!isLoading && !errorMessage && products.length === 0 ? (
        <EmptyState
          title="No matching products"
          description="Try a different product name or SKU, or head back home and browse the featured section."
          action={
            <Link href="/">
              <Button>Back home</Button>
            </Link>
          }
        />
      ) : null}
      {!isLoading && !errorMessage && products.length > 0 ? (
        <ProductGrid products={products} />
      ) : null}
    </PageContainer>
  );
}
  • 'use client' is required because this page uses client-side state and hooks such as useState and useDeferredValue. The shop page is an interactive search screen, so it belongs firmly on the client side.

  • searchValue stores the immediate input value typed by the user. This is the raw controlled input state that gets passed directly into SearchBar.

  • useDeferredValue(searchValue) produces a deferred version of that input. The important teaching point here is that useDeferredValue() can help keep typing responsive by letting more urgent updates, like the input itself, take priority over heavier rendering work.

  • However, useDeferredValue() does not replace a clean data hook. The actual fetching logic still belongs in useProducts(), which remains responsible for the request lifecycle, error handling, and refresh behavior.

  • The deferred query is trimmed before being passed into useProducts(), with empty strings turned into undefined. That keeps the search input clean and avoids sending meaningless blank queries to the product API module.

  • PageContainer and SectionHeading keep the shop page visually aligned with the rest of the storefront. Because those shared layout and heading primitives already exist, this component can focus on page orchestration instead of rebuilding common UI structure.

  • SearchBar is used purely as a reusable input component. The page owns the state and data flow, while the search bar remains a simple controlled UI primitive.

  • The conditional rendering block is the real orchestration job of this page. It decides whether the user should see a LoadingState, an ErrorState, an EmptyState, or the ProductGrid, based on the state returned from useProducts().

  • The empty state includes an action leading back home, which is a thoughtful UX detail. It gives the user something useful to do instead of only announcing that no products matched.

This component is a strong example of a page component doing the right kind of work: coordinating shared pieces instead of trying to own every responsibility itself.

Reusing the Same Product Hook on the Home Page

The home page also needs real product data now, but it should not invent a separate loading mechanism. The file src/components/home/HomePageClient.tsx has been updated so it also uses useProducts(), then derives a smaller featuredProducts slice from the returned collection.

'use client';

import Link from 'next/link';
import { PageContainer } from '@/components/layout/PageContainer';
import { ProductGrid } from '@/components/products/ProductGrid';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { ErrorState } from '@/components/ui/ErrorState';
import { LoadingState } from '@/components/ui/LoadingState';
import { SectionHeading } from '@/components/ui/SectionHeading';
import { useProducts } from '@/lib/hooks/useProducts';

export function HomePageClient() {
  const { products, isLoading, errorMessage } = useProducts();
  const featuredProducts = products.slice(0, 3);

  return (
    <div className="pb-20">
      <section className="overflow-hidden">
        <PageContainer className="grid gap-12 py-14 lg:grid-cols-[1.1fr_0.9fr] lg:items-center lg:py-20">
          <div className="space-y-8">
            <div className="space-y-5">
              <p className="text-xs font-semibold uppercase tracking-[0.35em] text-amber-700">
                Seasonal collection
              </p>
              <h1 className="max-w-3xl font-serif text-5xl tracking-tight text-stone-950 sm:text-6xl">
                Well-made essentials for everyday spaces.
              </h1>
              <p className="max-w-2xl text-base leading-7 text-stone-600">
                Browse live inventory, open product details, and start the
                customer journey with a storefront that already speaks to the
                backend.
              </p>
            </div>
            <div className="flex flex-wrap gap-3">
              <Link href="/shop">
                <Button size="lg">Browse the catalog</Button>
              </Link>
            </div>
          </div>
        </PageContainer>
      </section>

      <section className="py-8">
        <PageContainer className="space-y-8">
          <SectionHeading
            eyebrow="Featured"
            title="A few products to start the shopping flow"
            description="A quick look at what is currently available."
            action={
              <Link href="/shop">
                <Button variant="secondary">See full shop</Button>
              </Link>
            }
          />
          {isLoading ? (
            <LoadingState message="Loading featured products..." />
          ) : null}
          {!isLoading && errorMessage ? (
            <ErrorState message={errorMessage} />
          ) : null}
          {!isLoading && !errorMessage && featuredProducts.length === 0 ? (
            <EmptyState
              title="No products available yet"
              description="Once products exist in the backend, they will appear here as featured inventory."
            />
          ) : null}
          {!isLoading && !errorMessage && featuredProducts.length > 0 ? (
            <ProductGrid products={featuredProducts} />
          ) : null}
        </PageContainer>
      </section>
    </div>
  );
}
  • The most important change here is that the home page now calls useProducts() too. This is a strong frontend design choice because both the home page and the shop page rely on the same shared product-loading mechanism, even though they present the data differently.

  • featuredProducts is derived by slicing the first three items from the loaded product collection. This keeps the home page focused and lightweight while still using the real live product source.

  • The hero section remains mostly a presentation layer, but its supporting copy now reflects that the storefront is connected to live inventory. That helps the page’s messaging stay aligned with the app’s actual capabilities.

  • The featured section uses the same shared state components introduced earlier: LoadingState, ErrorState, EmptyState, and ProductGrid. This is another good example of reuse: the page is not inventing one-off handling for its data state.

  • The “See full shop” action connects the featured preview to the dedicated catalog page. That creates a smooth relationship between the home and shop experiences while still letting each page serve a different role.

This is a great example of shared mechanisms and different presentations. The hook is shared, but the page-specific orchestration remains distinct.

The Root Route Still Stays Small

Even though the home page now loads real products, the route file itself remains intentionally tiny. The file src/app/page.tsx still delegates page rendering to the home page client component.

import { HomePageClient } from '@/components/home/HomePageClient';

export default function Page() {
  return <HomePageClient />;
}
  • This shows that adding real data does not require route files to become bloated. The route still identifies which page component to render, while the actual data loading and UI branching stay inside the dedicated page component and shared hook.

  • Keeping this pattern stable across both / and /shop makes the codebase more predictable. When learners open a route file, they can quickly understand that the main composition work probably lives in a dedicated component nearby.

How This Fits with the Existing Shell

Even though this lesson focuses on API communication and product data, it still builds directly on the layout and UI primitives from the previous lesson. Files such as src/app/layout.tsx, src/components/layout/AppShell.tsx, src/components/layout/PageContainer.tsx, src/components/ui/Button.tsx, src/components/ui/SectionHeading.tsx, src/components/ui/EmptyState.tsx, src/components/ui/LoadingState.tsx, src/components/ui/ErrorState.tsx, src/components/ui/StatusBadge.tsx, and src/lib/utils/format.ts continue to do their jobs unchanged.

That continuity matters. Because those lower-level layout and presentation pieces were already stabilized, the new data layer can plug into the UI cleanly instead of forcing you to rethink the shell. This is exactly the benefit of structuring a course — and a codebase — around reusable layers.

Recap

In this lesson, you turned the storefront from a polished shell into the beginning of a real data-driven application. You defined shared domain models in src/lib/types/domain.ts, modeled API success and error envelopes in src/lib/types/api.ts, added request DTOs in src/lib/types/dto.ts, and exposed clean import surfaces through src/types/domain/index.ts and src/types/api/index.ts.

You then built the API stack in layers. src/lib/api/client.ts became the low-level reusable transport helper. src/lib/api/products.ts became the product-specific request module. src/lib/hooks/useProducts.ts took ownership of the async loading lifecycle, including stale request protection through requestIdRef.

On the UI side, you introduced reusable catalog pieces through src/components/products/SearchBar.tsx, src/components/products/ProductCard.tsx, and src/components/products/ProductGrid.tsx. Then you used them in src/components/shop/ShopPageClient.tsx, where the page orchestrates search state and async rendering with useDeferredValue() and useProducts(). Finally, you updated src/components/home/HomePageClient.tsx so the home page also consumes the same shared product-loading mechanism and displays a featured subset.

The big takeaway is that a good frontend does not let pages talk to the backend in ad hoc ways. Shared types define the contract, API helpers handle transport, hooks manage async state, and components focus on rendering. That layered structure is what will keep the storefront readable and scalable as the rest of the customer journey comes online.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal