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
- Build the typed client and the list page. Confirm products render.
- Disable JavaScript and reload. Confirm the products are still there.
- View source and find your title, description and structured data in the raw HTML.
- Add
loading.tsxand slow the API by a second to see it. - Visit a slug that does not exist. Confirm the Network tab shows 404, not 200.
- Build the product page and variant picker. Switch variants and confirm the price and per-unit line update.
- Confirm the out-of-stock variant is visible and marked.
- Check the JavaScript bundle for the product page. Confirm the description text is not in it.
- Validate your structured data with Google's Rich Results Test.
- Remove the undefined-parameter filter and load
/productswith 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