Tutorial: Build a Product Page + Cart
Server Component fetch, variant selection, and a progressively-enhanced add-to-cart form — end to end.
This walks through exactly what prebit-template-store's
app/products/[slug]/page.tsx does, step by step.
1. Fetch on the server
// app/products/[slug]/page.tsx
import { getProduct, getProductMetadata, NotFoundError } from "@prebit/boron/server";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
interface PageProps {
params: Promise<{ slug: string }>;
}
async function loadProduct(slug: string) {
return getProduct(slug).catch((err) => {
if (err instanceof NotFoundError) return null;
throw err;
});
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const product = await loadProduct(slug);
return product ? getProductMetadata(product) : {};
}No hook, no client component needed just to read data — getProduct is a
plain async function, called directly in a Server Component.
2. Render, with real SEO metadata already handled
export default async function ProductPage({ params }: PageProps) {
const { slug } = await params;
const product = await loadProduct(slug);
if (!product) notFound();
return (
<Product product={product}>
<Img src={product.imageUrl} alt={product.name} width={600} height={600} priority />
<Money amount={product.discountedPrice || product.price} />
{product.variants.length > 0 && <VariantSelector />}
</Product>
);
}generateMetadata above already returned real title/description/
openGraph — nothing extra to wire up for SEO.
3. Add to cart, working with JS disabled
import { addToCartAction } from "@prebit/boron/actions";
<form
action={async (formData: FormData) => {
"use server";
await addToCartAction(formData);
}}
>
<input type="hidden" name="productId" value={product.id} />
<button type="submit">Add to cart</button>
</form>This is a real HTML form submission under the hood — disable JavaScript in your browser's devtools and it still works, because the Server Action handles the mutation and Next.js re-renders the page with the result.
4. Show the cart count in the header
// app/_components/CartLink.tsx
"use client";
import { useCart } from "@prebit/boron/client";
export function CartLink() {
const { cart } = useCart();
return <a href="/cart">Cart ({cart.itemCount})</a>;
}This is the one place JavaScript is required — a live cart badge needs client-side state. Everything else on this page works without it.
5. Recommendations, same pattern as the product itself
import { getProductRecommendations } from "@prebit/boron/server";
const recommended = await getProductRecommendations(slug, { limit: 4 });Where to go from here
components/for<Pagination>,<OptimisticCart>, and building your ownProductCardfrom the primitives.server/for cache tags andgenerateStaticParams()if you want SSG/ISR instead of the template's defaultforce-dynamicrendering.- The full working version:
prebit-template-store's ownapp/products/[slug]/page.tsx.