RizTech Academy logo
RizTech Academy
Kirana Store: Catalogue and SearchLesson 3 of 540 min

The storefront and product grid

The API serves the catalogue. This lesson builds the pages a customer actually sees — and it is where modules 3 and 4 stop being theory.

The typed client

One place that knows how to talk to the API:

// apps/web/src/lib/api.ts
import type { Paginated, ProductDetail, ProductSummary } from "@kirana/shared";

const BASE = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL!;

export class ApiError extends Error {
  constructor(public readonly status: number, message: string) {
    super(message);
  }
}

async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  const response = await fetch(`${BASE}${path}`, {
    ...init,
    next: { revalidate: 60, tags: ["products"], ...(init as any).next },
  });

  if (!response.ok) {
    const body = await response.json().catch(() => null);
    throw new ApiError(response.status, body?.message ?? "Request failed");
  }

  return response.json() as Promise<T>;
}

export const api = {
  categories: {
    list: () => request<CategoryOption[]>("/categories"),
  },
  products: {
    list: (params: Record<string, string | undefined>) => {
      const query = new URLSearchParams(
        Object.entries(params).filter(([, v]) => v !== undefined) as [string, string][],
      );
      return request<Paginated<ProductSummary>>(`/products?${query}`);
    },
    get: (slug: string) => request<ProductDetail>(`/products/${slug}`),
  },
};

revalidate: 60 by default. A minute of staleness on a catalogue is acceptable and takes enormous load off the API. The tags: ["products"] lets the admin screens in module 13 invalidate it the moment a price changes.

Undefined parameters are filtered out, so an absent category does not become ?category=undefined — which would be a real filter value and match nothing.

The product list page

// apps/web/src/app/(shop)/products/page.tsx
import type { Metadata } from "next";
import { api } from "@/lib/api";
import { ProductGrid } from "@/components/product/product-grid";
import { CategoryFilter } from "@/components/product/category-filter";
import { Pagination } from "@/components/ui/pagination";

type Props = {
  searchParams: Promise<{
    category?: string;
    q?: string;
    page?: string;
    sort?: string;
  }>;
};

export const metadata: Metadata = {
  title: "All products",
  description:
    "Groceries, staples and household essentials from your neighbourhood kirana shop. Same-day delivery in Wagholi, Pune.",
};

export default async function ProductsPage({ searchParams }: Props) {
  const params = await searchParams;

  const [categories, products] = await Promise.all([
    api.categories.list(),
    api.products.list({
      category: params.category,
      q: params.q,
      sort: params.sort,
      page: params.page,
    }),
  ]);

  return (
    <div className="py-6">
      <h1 className="text-2xl font-semibold">
        {params.q ? `Results for "${params.q}"` : "All products"}
      </h1>
      <p className="mt-1 text-sm text-gray-600">
        {products.total} {products.total === 1 ? "product" : "products"}
      </p>

      <div className="mt-5">
        <CategoryFilter categories={categories} />
      </div>

      <div className="mt-6">
        <ProductGrid products={products.items} />
      </div>

      <Pagination {...products} />
    </div>
  );
}

Three things.

A server component. No useEffect, no loading state, and the HTML arrives complete — which is what makes it indexable, from module 3.

Promise.all so the categories and products fetch together rather than one after the other.

searchParams is awaited, and every value is a string. The API DTO converts them, so the page passes them through untouched.

Loading and errors

// apps/web/src/app/(shop)/products/loading.tsx
import { ProductGridSkeleton } from "@/components/product/product-grid-skeleton";

export default function Loading() {
  return (
    <div className="py-6">
      <div className="h-8 w-48 animate-pulse rounded bg-gray-100" />
      <div className="mt-6">
        <ProductGridSkeleton count={8} />
      </div>
    </div>
  );
}
// apps/web/src/app/(shop)/products/error.tsx
"use client";

import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";

export default function Error({ reset }: { error: Error; reset: () => void }) {
  return (
    <EmptyState
      title="We could not load the products"
      description="This is usually temporary. Please try again."
      action={<Button onClick={reset}>Try again</Button>}
    />
  );
}

The skeleton matches the real layout, so nothing jumps. The error does not render error.message, for the reason in module 3.

The product page

// apps/web/src/app/(shop)/products/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { formatPaise } from "@kirana/shared";
import { api, ApiError } from "@/lib/api";
import { VariantPicker } from "@/components/product/variant-picker";
import { ProductImage } from "@/components/product/product-image";

type Props = { params: Promise<{ slug: string }> };

async function getProduct(slug: string) {
  try {
    return await api.products.get(slug);
  } catch (error) {
    if (error instanceof ApiError && error.status === 404) return null;
    throw error;
  }
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const product = await getProduct((await params).slug);
  if (!product) return { title: "Product not found" };

  const price = formatPaise(product.cheapestVariant.pricePaise);

  return {
    title: product.name,
    description:
      product.description ??
      `${product.name} — from ${price}. Order online for same-day delivery in Wagholi, Pune.`,
    alternates: { canonical: `/products/${product.slug}` },
    openGraph: {
      title: product.name,
      description: product.description ?? undefined,
      images: product.imageUrl ? [{ url: product.imageUrl }] : undefined,
    },
  };
}

export default async function ProductPage({ params }: Props) {
  const product = await getProduct((await params).slug);
  if (!product) notFound();

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.name,
    brand: product.brand ?? undefined,
    description: product.description ?? undefined,
    offers: product.variants.map((v) => ({
      "@type": "Offer",
      sku: v.sku,
      priceCurrency: "INR",
      price: (v.pricePaise / 100).toFixed(2),
      availability: v.inStock
        ? "https://schema.org/InStock"
        : "https://schema.org/OutOfStock",
    })),
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />

      <div className="grid gap-8 py-6 lg:grid-cols-2">
        <ProductImage src={product.imageUrl} alt={product.name} priority />

        <div>
          {product.brand && (
            <p className="text-sm text-gray-500">{product.brand}</p>
          )}
          <h1 className="mt-1 text-2xl font-semibold">{product.name}</h1>

          <div className="mt-6">
            <VariantPicker product={product} />
          </div>

          {product.description && (
            <div className="mt-8 border-t border-gray-200 pt-6">
              <h2 className="font-medium">About this product</h2>
              <p className="mt-2 text-sm leading-relaxed text-gray-600">
                {product.description}
              </p>
            </div>
          )}
        </div>
      </div>
    </>
  );
}

generateMetadata and the page both call getProduct — one request, because React deduplicates identical fetches within a render pass, from module 3.

A 404 from the API becomes notFound(), which sends a real 404 status. Anything else is re-thrown to error.tsx. A discontinued product returning 200 would tell Google to keep indexing it.

The structured data lists every variant as an offer, which is what produces a price range in search results.

The variant picker

The one client component on the page:

"use client";

import { useState } from "react";
import { formatPaise, pricePerUnit, type ProductDetail } from "@kirana/shared";
import { Badge } from "@/components/ui/badge";
import { AddToCartButton } from "@/components/cart/add-to-cart-button";

export function VariantPicker({ product }: { product: ProductDetail }) {
  const [selectedId, setSelectedId] = useState(product.variants[0]?.id);
  const selected = product.variants.find((v) => v.id === selectedId) ?? product.variants[0];

  if (!selected) return null;

  const perUnit = pricePerUnit(selected);
  const saving = selected.mrpPaise ? selected.mrpPaise - selected.pricePaise : 0;

  return (
    <div>
      {product.variants.length > 1 && (
        <div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Pack size">
          {product.variants.map((variant) => (
            <button
              key={variant.id}
              role="radio"
              aria-checked={variant.id === selected.id}
              onClick={() => setSelectedId(variant.id)}
              className={
                variant.id === selected.id
                  ? "rounded-lg border-2 border-emerald-600 px-4 py-2 text-sm font-medium"
                  : "rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700"
              }
            >
              {variant.label}
              {!variant.inStock && (
                <span className="ml-1 text-xs text-gray-400">(out of stock)</span>
              )}
            </button>
          ))}
        </div>
      )}

      <div className="mt-5 flex items-baseline gap-3">
        <span className="text-2xl font-semibold tabular-nums">
          {formatPaise(selected.pricePaise)}
        </span>
        {saving > 0 && (
          <>
            <span className="text-sm text-gray-500 line-through tabular-nums">
              {formatPaise(selected.mrpPaise!)}
            </span>
            <Badge tone="success">Save {formatPaise(saving)}</Badge>
          </>
        )}
      </div>

      {perUnit && (
        <p className="mt-1 text-sm text-gray-500">
          {formatPaise(perUnit.paise)} per {perUnit.unit}
        </p>
      )}

      <div className="mt-6">
        <AddToCartButton variant={selected} />
      </div>
    </div>
  );
}

The client boundary is as small as it can be — module 3's rule. The page, the image, the description and the structured data all stay on the server; only the picker ships JavaScript.

Out-of-stock variants are shown and selectable, not hidden. A customer looking for the 10 kg pack should learn it exists and is unavailable, not conclude the shop does not sell it.

role="radiogroup" and aria-checked because these buttons behave as a single choice, and a screen reader needs to know that.

Check your work

Why revalidate: 60 on the catalogue: a minute of staleness is acceptable and removes most of the load from the API. Tags allow immediate invalidation when it matters.

Why undefined parameters are filtered: otherwise an absent filter becomes the literal string undefined and matches nothing.

Why the list page is a server component: no loading state, and the HTML arrives complete so it can be indexed.

Why generateMetadata calling the API does not double the requests: React deduplicates identical fetches within a render pass.

Why a 404 becomes notFound(): it sends a real 404 status. A 200 saying "not found" keeps the page in Google's index.

Why out-of-stock variants are shown rather than hidden: a customer should learn the size exists and is unavailable, not that it is not sold.

Where the client boundary goes: around the variant picker only, so the rest of the page ships no JavaScript.

Practice

  1. Build the typed client and the list page. Confirm products render.
  2. Disable JavaScript and reload. Confirm the products are still there.
  3. View source and find your title, description and structured data in the raw HTML.
  4. Add loading.tsx and slow the API by a second to see it.
  5. Visit a slug that does not exist. Confirm the Network tab shows 404, not 200.
  6. Build the product page and variant picker. Switch variants and confirm the price and per-unit line update.
  7. Confirm the out-of-stock variant is visible and marked.
  8. Check the JavaScript bundle for the product page. Confirm the description text is not in it.
  9. Validate your structured data with Google's Rich Results Test.
  10. Remove the undefined-parameter filter and load /products with no filters. Watch it return nothing.

Next: search and filters.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship