This is a headless e-commerce storefront built with Next.js 16 and React 19, using the Spree Commerce API v3 via @spree/sdk.
- Framework: Next.js 16 (App Router)
- React: 19 (with new features like
use(), Actions, improved Suspense) - Styling: Tailwind CSS
- API Client:
@spree/sdk - Language: TypeScript
src/
├── app/ # Next.js App Router
│ └── [country]/[locale]/ # Internationalized routes
│ ├── (checkout)/ # Checkout route group (minimal layout)
│ └── (storefront)/ # Storefront route group (full layout)
├── components/ # Reusable UI components
├── contexts/ # React Context providers
├── lib/
│ └── data/ # Server Actions for data fetching
└── types/ # TypeScript type definitions
React 19 provides better patterns for many cases where useEffect was previously required. Follow https://react.dev/learn/you-might-not-need-an-effect
1. Transforming data for rendering
// ❌ Bad - useEffect for derived state
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// ✅ Good - compute during render
const fullName = `${firstName} ${lastName}`;
// ✅ Good - useMemo for expensive calculations
const sortedProducts = useMemo(
() => products.sort((a, b) => a.price - b.price),
[products]
);2. Resetting state when props change
// ❌ Bad - useEffect to reset state
useEffect(() => {
setSelectedVariant(null);
}, [productId]);
// ✅ Good - use key to reset component state
<ProductDetails key={productId} product={product} />
// ✅ Good - compute initial state from props
const [selectedVariant, setSelectedVariant] = useState(() => {
return product.default_variant || product.variants[0];
});3. Fetching data in response to user events
// ❌ Bad - useEffect triggered by state
const [query, setQuery] = useState("");
useEffect(() => {
fetchResults(query);
}, [query]);
// ✅ Good - fetch in event handler
const handleSearch = async (searchQuery: string) => {
setQuery(searchQuery);
const results = await fetchResults(searchQuery);
setResults(results);
};
// ✅ Better - use Server Actions
const [results, searchAction] = useActionState(searchProducts, []);4. Initializing the application
// ❌ Bad - useEffect for one-time init
useEffect(() => {
loadAnalytics();
}, []);
// ✅ Good - module-level initialization
if (typeof window !== "undefined") {
loadAnalytics();
}
// ✅ Good - check if already initialized
let didInit = false;
function App() {
if (!didInit) {
didInit = true;
loadAnalytics();
}
return null;
}- Synchronizing with external systems (DOM APIs, third-party widgets, network)
- Setting up subscriptions (WebSocket, event listeners)
- Browser-only effects (focus management, scroll position)
- Intersection Observer, Resize Observer
// ✅ Good - Server Component (default, no "use client")
// src/app/[country]/[locale]/(storefront)/products/page.tsx
import { getProducts } from "@/lib/data/products";
export default async function ProductsPage() {
const products = await getProducts();
return <ProductList products={products} />;
}Only add "use client" when you need:
- Event handlers (onClick, onChange, etc.)
- useState, useReducer, useEffect, useContext
- Browser-only APIs
- Custom hooks that use state/effects
// src/lib/data/cart.ts
"use server";
export async function addToCart(variantId: string, quantity: number) {
const cart = await getOrCreateCart();
const client = await getSpreeClient();
return client.orders.lineItems.create(
cart.id,
{ variant_id: variantId, quantity },
{ orderToken: cart.token }
);
}
// Component usage
import { addToCart } from "@/lib/data/cart";
function AddToCartButton({ variantId }: { variantId: string }) {
const [isPending, startTransition] = useTransition();
const handleClick = () => {
startTransition(async () => {
await addToCart(variantId, 1);
});
};
return (
<button onClick={handleClick} disabled={isPending}>
{isPending ? "Adding..." : "Add to Cart"}
</button>
);
}// ✅ Good - use() with Suspense
import { use, Suspense } from "react";
interface ProductDetailsProps {
productPromise: Promise<Product>;
}
function ProductDetails({ productPromise }: ProductDetailsProps) {
const product = use(productPromise);
return <div>{product.name}</div>;
}
// Parent component
function ProductPage({ id }: { id: string }) {
const productPromise = getProduct(id); // Don't await here
return (
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails productPromise={productPromise} />
</Suspense>
);
}// ✅ Good - useActionState for form handling
"use client";
import { useActionState } from "react";
import { updateProfile } from "@/lib/data/customer";
function ProfileForm({ user }: { user: User }) {
const [state, formAction, isPending] = useActionState(updateProfile, {
error: null,
success: false,
});
return (
<form action={formAction}>
<input name="firstName" defaultValue={user.first_name} />
<input name="lastName" defaultValue={user.last_name} />
<button disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</button>
{state.error && <p className="text-red-500">{state.error}</p>}
</form>
);
}// ✅ Good - optimistic updates
import { useOptimistic } from "react";
interface CartItemProps {
item: LineItem;
onUpdate: (id: string, quantity: number) => Promise<void>;
}
function CartItem({ item, onUpdate }: CartItemProps) {
const [optimisticQuantity, setOptimisticQuantity] = useOptimistic(item.quantity);
const handleQuantityChange = async (newQuantity: number) => {
setOptimisticQuantity(newQuantity);
await onUpdate(item.id, newQuantity);
};
return (
<div>
<span>{item.name}</span>
<span>Qty: {optimisticQuantity}</span>
<button onClick={() => handleQuantityChange(optimisticQuantity + 1)}>+</button>
</div>
);
}app/[country]/[locale]/
├── (checkout)/ # Minimal layout for checkout
│ ├── layout.tsx
│ └── checkout/[id]/page.tsx
├── (storefront)/ # Full layout with header/footer
│ ├── layout.tsx
│ ├── page.tsx
│ └── products/
└── layout.tsx # Shared locale/currency provider
// ✅ Good - parallel fetches
interface ProductPageProps {
params: Promise<{ slug: string }>;
}
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = await params;
const [product, relatedProducts, reviews] = await Promise.all([
getProduct(slug),
getRelatedProducts(slug),
getProductReviews(slug),
]);
return (
<>
<ProductDetails product={product} />
<RelatedProducts products={relatedProducts} />
<Reviews reviews={reviews} />
</>
);
}// app/products/loading.tsx
export default function Loading() {
return <ProductGridSkeleton />;
}
// Or use Suspense boundaries for more granular loading
export default async function Page() {
return (
<div>
<Suspense fallback={<ProductInfoSkeleton />}>
<ProductInfo />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
</div>
);
}// For static generation with dynamic routes
export async function generateStaticParams() {
const products = await getAllProducts();
return products.map((product) => ({ slug: product.slug }));
}
// For dynamic rendering when needed
export const dynamic = "force-dynamic";import type { Metadata } from "next";
interface MetadataProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: MetadataProps): Promise<Metadata> {
const { slug } = await params;
const product = await getProduct(slug);
return {
title: product.meta_title || product.name,
description: product.meta_description,
openGraph: {
images: product.images.map((img) => img.url),
},
};
}// src/lib/data/products.ts
"use server";
import { getSpreeClient } from "@/lib/spree";
export async function getProducts(params?: ProductListParams) {
const client = await getSpreeClient();
return client.products.list({
per_page: 12,
includes: "images,default_variant",
...params,
});
}// src/lib/data/auth.ts
"use server";
import { cookies } from "next/headers";
import { getSpreeClient } from "@/lib/spree";
const SEVEN_DAYS = 60 * 60 * 24 * 7;
export async function login(email: string, password: string) {
const client = await getSpreeClient();
const { token, user } = await client.auth.login({ email, password });
const cookieStore = await cookies();
cookieStore.set("spree_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SEVEN_DAYS,
});
return user;
}
export async function getAuthToken() {
const cookieStore = await cookies();
return cookieStore.get("spree_token")?.value;
}// Guest carts use order tokens stored in cookies
const THIRTY_DAYS = 60 * 60 * 24 * 30;
export async function getCartToken() {
const cookieStore = await cookies();
return cookieStore.get("spree_cart_token")?.value;
}
export async function setCartToken(token: string) {
const cookieStore = await cookies();
cookieStore.set("spree_cart_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: THIRTY_DAYS,
});
}Only use Context for truly global state that many components need:
CartContext- Cart state and actionsAuthContext- Authentication stateStoreContext- Current store, locale, currency
For component-local state, prefer:
useStatefor simple state- URL search params for filter/sort state (shareable, bookmarkable)
- Server state via Server Components
// ✅ Good - filters in URL
"use client";
import { useSearchParams, useRouter } from "next/navigation";
function ProductFilters() {
const searchParams = useSearchParams();
const router = useRouter();
const updateFilter = (key: string, value: string) => {
const params = new URLSearchParams(searchParams);
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
router.push(`?${params.toString()}`);
};
return (
<select
value={searchParams.get("sort") || ""}
onChange={(e) => updateFilter("sort", e.target.value)}
>
<option value="">Default</option>
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
</select>
);
}import type {
StoreProduct,
StoreVariant,
StoreOrder,
StoreLineItem,
PaginatedResponse,
} from "@spree/sdk";
interface ProductCardProps {
product: StoreProduct;
basePath: string;
}The project uses strict TypeScript. Always:
- Define explicit return types for functions
- Use
satisfiesfor type checking object literals - Avoid
any, useunknownif type is truly unknown
import Image from "next/image";
interface ProductImageProps {
image: StoreImage;
}
function ProductImage({ image }: ProductImageProps) {
return (
<Image
src={image.url}
alt={image.alt || ""}
width={800}
height={800}
className="object-cover"
priority={false}
placeholder="blur"
blurDataURL={image.thumbnail_url}
/>
);
}import dynamic from "next/dynamic";
const ProductReviews = dynamic(
() => import("./ProductReviews"),
{ loading: () => <ReviewsSkeleton /> }
);- Use Playwright for E2E tests
- Use React Testing Library for component tests
- Test Server Actions independently
- Use functional components only
- Prefer named exports for components
- Use absolute imports (
@/components/...) - Follow Tailwind CSS conventions for styling
- Keep components small and focused
This project uses Biome for linting and formatting (not ESLint).
# Lint the codebase
npm run lint
# Format all files
npm run format
# Run both lint and format checks
npm run checkAlways use npm run check before committing changes and fix any issues with npm run format.
Biome is configured in biome.json using default formatting rules:
- Formatter: 2-space indentation, double quotes, semicolons
- Linter: Recommended rules with project-specific adjustments
// ✅ Good - use template literals for string interpolation
const message = `Hello, ${name}!`;
const path = `${basePath}/products/${slug}`;
// ❌ Bad - string concatenation
const message = "Hello, " + name + "!";
const path = basePath + "/products/" + slug;Biome warns about unused variables and imports. Remove them or prefix with underscore if intentionally unused:
// ✅ Good - remove unused imports
import { useState } from "react";
// ✅ Good - prefix intentionally unused params
const handleClick = (_event: MouseEvent) => {
// event not needed but required by type
};
// ❌ Bad - unused import
import { useState, useEffect } from "react"; // useEffect not used