Building a Complete Storefront
Learn how to build a production-ready e-commerce storefront from scratch using the Unified Commerce Platform. This guide covers everything from initial setup to deployment.
Prerequisites
Before starting, ensure you have:
- Node.js 18+ installed
- An active Unified Commerce account
- API credentials (get them from Dashboard)
- Basic knowledge of React/Next.js (or your preferred framework)
Table of Contents
- Project Setup
- Authentication
- Product Catalog
- Shopping Cart
- Checkout Flow
- User Accounts
- Order Management
- Search & Filtering
- Performance Optimization
- Deployment
- Troubleshooting
Project Setup
Step 1: Initialize Your Project
We'll use Next.js for this guide, but the concepts apply to any framework.
# Create a new Next.js app with TypeScript
npx create-next-app@latest storefront --typescript --tailwind --app
# Navigate to project directory
cd storefront
# Install Unified Commerce SDK and dependencies
npm install @unifiedcommerce/sdk graphql graphql-request swr zustand
Step 2: Configure Environment Variables
Create a .env.local
file in your project root:
# Unified Commerce API
NEXT_PUBLIC_UC_ENDPOINT=https://gateway.unifiedcommerce.app/graphql
NEXT_PUBLIC_UC_API_KEY=your_api_key_here
NEXT_PUBLIC_UC_MERCHANT_ID=merchant_abc123
# Stripe (for payments)
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
# App Configuration
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_SITE_NAME=My Store
Step 3: Set Up API Client
Create lib/api-client.ts
:
import { GraphQLClient } from 'graphql-request';
import { getSdk } from '@unifiedcommerce/sdk';
// Initialize GraphQL client
export const client = new GraphQLClient(
process.env.NEXT_PUBLIC_UC_ENDPOINT!,
{
headers: {
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_UC_API_KEY}`,
'X-Merchant-ID': process.env.NEXT_PUBLIC_UC_MERCHANT_ID!,
},
}
);
// Initialize SDK
export const sdk = getSdk(client);
// Helper for server-side requests
export const serverClient = new GraphQLClient(
process.env.NEXT_PUBLIC_UC_ENDPOINT!,
{
headers: {
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_UC_API_KEY}`,
'X-Merchant-ID': process.env.NEXT_PUBLIC_UC_MERCHANT_ID!,
},
cache: 'no-store', // Disable caching for server components
}
);
Step 4: Create Type Definitions
Create types/commerce.ts
:
// Product types
export interface Product {
id: string;
name: string;
slug: string;
description: string;
price: number;
currency: string;
images: ProductImage[];
variants: ProductVariant[];
category: Category;
inventory: InventoryLevel;
metadata: Record<string, any>;
}
export interface ProductImage {
id: string;
url: string;
alt: string;
order: number;
}
export interface ProductVariant {
id: string;
name: string;
sku: string;
price: number;
inventory: InventoryLevel;
attributes: VariantAttribute[];
}
export interface VariantAttribute {
name: string;
value: string;
}
// Cart types
export interface Cart {
id: string;
items: CartItem[];
subtotal: number;
tax: number;
shipping: number;
total: number;
currency: string;
discounts: Discount[];
}
export interface CartItem {
id: string;
product: Product;
variant?: ProductVariant;
quantity: number;
price: number;
total: number;
}
// Order types
export interface Order {
id: string;
orderNumber: string;
status: OrderStatus;
items: OrderItem[];
subtotal: number;
tax: number;
shipping: number;
total: number;
currency: string;
customer: Customer;
shippingAddress: Address;
billingAddress: Address;
createdAt: string;
updatedAt: string;
}
export enum OrderStatus {
PENDING = 'PENDING',
PROCESSING = 'PROCESSING',
SHIPPED = 'SHIPPED',
DELIVERED = 'DELIVERED',
CANCELLED = 'CANCELLED',
REFUNDED = 'REFUNDED',
}
Authentication
Step 1: Create Auth Context
Create contexts/auth-context.tsx
:
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import { sdk } from '@/lib/api-client';
interface AuthContextType {
user: User | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => Promise<void>;
updateProfile: (data: UpdateProfileData) => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Check for existing session on mount
useEffect(() => {
checkSession();
}, []);
const checkSession = async () => {
try {
const token = localStorage.getItem('auth_token');
if (!token) {
setIsLoading(false);
return;
}
const { data } = await sdk.getCurrentUser();
if (data?.me) {
setUser(data.me);
}
} catch (error) {
console.error('Session check failed:', error);
localStorage.removeItem('auth_token');
} finally {
setIsLoading(false);
}
};
const login = async (email: string, password: string) => {
try {
const { data } = await sdk.login({ email, password });
if (data?.login?.token) {
localStorage.setItem('auth_token', data.login.token);
setUser(data.login.user);
// Update API client headers
sdk.setAuthToken(data.login.token);
} else if (data?.login?.errors) {
throw new Error(data.login.errors[0].message);
}
} catch (error) {
console.error('Login failed:', error);
throw error;
}
};
const register = async (data: RegisterData) => {
try {
const { data: result } = await sdk.register({
input: {
email: data.email,
password: data.password,
firstName: data.firstName,
lastName: data.lastName,
phone: data.phone,
}
});
if (result?.register?.token) {
localStorage.setItem('auth_token', result.register.token);
setUser(result.register.user);
sdk.setAuthToken(result.register.token);
} else if (result?.register?.errors) {
throw new Error(result.register.errors[0].message);
}
} catch (error) {
console.error('Registration failed:', error);
throw error;
}
};
const logout = async () => {
try {
await sdk.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
localStorage.removeItem('auth_token');
setUser(null);
sdk.clearAuthToken();
}
};
const updateProfile = async (data: UpdateProfileData) => {
try {
const { data: result } = await sdk.updateProfile({ input: data });
if (result?.updateProfile?.user) {
setUser(result.updateProfile.user);
} else if (result?.updateProfile?.errors) {
throw new Error(result.updateProfile.errors[0].message);
}
} catch (error) {
console.error('Profile update failed:', error);
throw error;
}
};
return (
<AuthContext.Provider value={{
user,
isLoading,
login,
register,
logout,
updateProfile,
}}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
Step 2: Create Login Component
Create components/auth/login-form.tsx
:
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/auth-context';
import { useRouter } from 'next/navigation';
export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(email, password);
router.push('/account');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded border-gray-300"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full rounded border-gray-300"
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
);
}
Product Catalog
Step 1: Create Product List Page
Create app/products/page.tsx
:
import { serverClient } from '@/lib/api-client';
import { ProductGrid } from '@/components/products/product-grid';
import { CategoryFilter } from '@/components/products/category-filter';
import { SortDropdown } from '@/components/products/sort-dropdown';
import { gql } from 'graphql-request';
const GET_PRODUCTS = gql`
query GetProducts(
$first: Int!
$after: String
$categoryId: ID
$sortBy: ProductSortField
$sortOrder: SortOrder
) {
products(
first: $first
after: $after
filters: { categoryId: $categoryId }
sortBy: $sortBy
sortOrder: $sortOrder
) {
edges {
cursor
node {
id
name
slug
description
price
currency
images(first: 1) {
url
alt
}
category {
id
name
}
inventory {
available
}
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
categories {
id
name
slug
productCount
}
}
`;
export default async function ProductsPage({
searchParams,
}: {
searchParams: { category?: string; sort?: string; page?: string };
}) {
const { data } = await serverClient.request(GET_PRODUCTS, {
first: 12,
after: searchParams.page,
categoryId: searchParams.category,
sortBy: searchParams.sort?.split('-')[0] || 'CREATED_AT',
sortOrder: searchParams.sort?.split('-')[1] || 'DESC',
});
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">All Products</h1>
<div className="flex gap-8">
{/* Sidebar */}
<aside className="w-64 shrink-0">
<CategoryFilter
categories={data.categories}
selected={searchParams.category}
/>
</aside>
{/* Main content */}
<main className="flex-1">
<div className="flex justify-between items-center mb-6">
<p className="text-gray-600">
{data.products.totalCount} products
</p>
<SortDropdown selected={searchParams.sort} />
</div>
<ProductGrid products={data.products.edges.map(e => e.node)} />
{/* Pagination */}
{data.products.pageInfo.hasNextPage && (
<div className="mt-8 text-center">
<a
href={`?page=${data.products.pageInfo.endCursor}`}
className="inline-block bg-blue-600 text-white px-6 py-2 rounded"
>
Load More
</a>
</div>
)}
</main>
</div>
</div>
);
}
Step 2: Create Product Detail Page
Create app/products/[slug]/page.tsx
:
import { serverClient } from '@/lib/api-client';
import { ProductImages } from '@/components/products/product-images';
import { ProductInfo } from '@/components/products/product-info';
import { AddToCartButton } from '@/components/cart/add-to-cart-button';
import { gql } from 'graphql-request';
import { notFound } from 'next/navigation';
const GET_PRODUCT = gql`
query GetProduct($slug: String!) {
productBySlug(slug: $slug) {
id
name
slug
description
longDescription
price
compareAtPrice
currency
images {
id
url
alt
order
}
variants {
id
name
sku
price
inventory {
available
reserved
}
attributes {
name
value
}
}
category {
id
name
slug
}
inventory {
available
reserved
warehouse {
name
location
}
}
metadata
seoTitle
seoDescription
# Federated fields
reviews {
rating
count
}
relatedProducts(first: 4) {
id
name
slug
price
images(first: 1) {
url
alt
}
}
}
}
`;
export async function generateMetadata({ params }: { params: { slug: string } }) {
const { data } = await serverClient.request(GET_PRODUCT, {
slug: params.slug,
});
if (!data?.productBySlug) {
return {};
}
return {
title: data.productBySlug.seoTitle || data.productBySlug.name,
description: data.productBySlug.seoDescription || data.productBySlug.description,
openGraph: {
images: data.productBySlug.images[0]?.url,
},
};
}
export default async function ProductPage({
params,
}: {
params: { slug: string };
}) {
const { data } = await serverClient.request(GET_PRODUCT, {
slug: params.slug,
});
if (!data?.productBySlug) {
notFound();
}
const product = data.productBySlug;
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Images */}
<ProductImages images={product.images} />
{/* Product Info */}
<div className="space-y-6">
<ProductInfo product={product} />
{/* Add to Cart */}
<AddToCartButton
productId={product.id}
variants={product.variants}
inventory={product.inventory}
/>
{/* Additional Info */}
<div className="border-t pt-6">
<h3 className="font-semibold mb-2">Description</h3>
<div
className="prose prose-sm"
dangerouslySetInnerHTML={{
__html: product.longDescription || product.description,
}}
/>
</div>
</div>
</div>
{/* Related Products */}
{product.relatedProducts.length > 0 && (
<div className="mt-16">
<h2 className="text-2xl font-bold mb-6">Related Products</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{product.relatedProducts.map((related) => (
<a
key={related.id}
href={`/products/${related.slug}`}
className="group"
>
<img
src={related.images[0]?.url}
alt={related.images[0]?.alt}
className="w-full h-64 object-cover rounded"
/>
<h3 className="mt-2 font-medium group-hover:text-blue-600">
{related.name}
</h3>
<p className="text-gray-600">
${(related.price / 100).toFixed(2)}
</p>
</a>
))}
</div>
</div>
)}
</div>
);
}
Shopping Cart
Step 1: Create Cart Store
Create stores/cart-store.ts
:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { sdk } from '@/lib/api-client';
interface CartItem {
productId: string;
variantId?: string;
quantity: number;
product?: any; // Product details
}
interface CartStore {
items: CartItem[];
isLoading: boolean;
cartId: string | null;
// Actions
addItem: (productId: string, variantId?: string, quantity?: number) => Promise<void>;
updateQuantity: (productId: string, quantity: number) => Promise<void>;
removeItem: (productId: string) => Promise<void>;
clearCart: () => Promise<void>;
syncCart: () => Promise<void>;
// Computed
get itemCount(): number;
get subtotal(): number;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
isLoading: false,
cartId: null,
addItem: async (productId, variantId, quantity = 1) => {
set({ isLoading: true });
try {
// Check if item already exists
const existingItem = get().items.find(
item => item.productId === productId && item.variantId === variantId
);
if (existingItem) {
// Update quantity
await get().updateQuantity(productId, existingItem.quantity + quantity);
return;
}
// Create or update cart
const { data } = await sdk.addToCart({
input: {
cartId: get().cartId,
productId,
variantId,
quantity,
}
});
if (data?.addToCart?.cart) {
set({
cartId: data.addToCart.cart.id,
items: data.addToCart.cart.items,
});
}
} catch (error) {
console.error('Failed to add item to cart:', error);
throw error;
} finally {
set({ isLoading: false });
}
},
updateQuantity: async (productId, quantity) => {
if (quantity <= 0) {
return get().removeItem(productId);
}
set({ isLoading: true });
try {
const { data } = await sdk.updateCartItem({
cartId: get().cartId!,
productId,
quantity,
});
if (data?.updateCartItem?.cart) {
set({ items: data.updateCartItem.cart.items });
}
} catch (error) {
console.error('Failed to update quantity:', error);
throw error;
} finally {
set({ isLoading: false });
}
},
removeItem: async (productId) => {
set({ isLoading: true });
try {
const { data } = await sdk.removeFromCart({
cartId: get().cartId!,
productId,
});
if (data?.removeFromCart?.cart) {
set({ items: data.removeFromCart.cart.items });
}
} catch (error) {
console.error('Failed to remove item:', error);
throw error;
} finally {
set({ isLoading: false });
}
},
clearCart: async () => {
set({ isLoading: true });
try {
await sdk.clearCart({ cartId: get().cartId! });
set({ items: [], cartId: null });
} catch (error) {
console.error('Failed to clear cart:', error);
throw error;
} finally {
set({ isLoading: false });
}
},
syncCart: async () => {
const cartId = get().cartId;
if (!cartId) return;
try {
const { data } = await sdk.getCart({ id: cartId });
if (data?.cart) {
set({ items: data.cart.items });
} else {
// Cart no longer exists
set({ items: [], cartId: null });
}
} catch (error) {
console.error('Failed to sync cart:', error);
}
},
get itemCount() {
return get().items.reduce((sum, item) => sum + item.quantity, 0);
},
get subtotal() {
return get().items.reduce(
(sum, item) => sum + (item.product?.price || 0) * item.quantity,
0
);
},
}),
{
name: 'cart-storage',
partialize: (state) => ({ cartId: state.cartId }),
}
)
);
Step 2: Create Cart Components
Create components/cart/cart-drawer.tsx
:
'use client';
import { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { useCartStore } from '@/stores/cart-store';
import { CartItem } from './cart-item';
interface CartDrawerProps {
isOpen: boolean;
onClose: () => void;
}
export function CartDrawer({ isOpen, onClose }: CartDrawerProps) {
const { items, itemCount, subtotal, clearCart } = useCartStore();
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75" />
</Transition.Child>
<div className="fixed inset-0 overflow-hidden">
<div className="absolute inset-0 overflow-hidden">
<div className="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-300"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-300"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<Dialog.Panel className="pointer-events-auto w-screen max-w-md">
<div className="flex h-full flex-col bg-white shadow-xl">
{/* Header */}
<div className="flex-1 overflow-y-auto px-4 py-6 sm:px-6">
<div className="flex items-start justify-between">
<Dialog.Title className="text-lg font-medium">
Shopping Cart ({itemCount})
</Dialog.Title>
<button
type="button"
className="ml-3 flex h-7 w-7 items-center justify-center"
onClick={onClose}
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
{/* Cart Items */}
<div className="mt-8">
{items.length === 0 ? (
<p className="text-center text-gray-500">
Your cart is empty
</p>
) : (
<div className="flow-root">
<ul className="-my-6 divide-y divide-gray-200">
{items.map((item) => (
<CartItem key={item.productId} item={item} />
))}
</ul>
</div>
)}
</div>
</div>
{/* Footer */}
{items.length > 0 && (
<div className="border-t border-gray-200 px-4 py-6 sm:px-6">
<div className="flex justify-between text-base font-medium">
<p>Subtotal</p>
<p>${(subtotal / 100).toFixed(2)}</p>
</div>
<p className="mt-0.5 text-sm text-gray-500">
Shipping and taxes calculated at checkout.
</p>
<div className="mt-6">
<a
href="/checkout"
className="flex items-center justify-center rounded-md bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
>
Checkout
</a>
</div>
<div className="mt-6 flex justify-center text-center text-sm">
<p>
or{' '}
<button
type="button"
className="font-medium text-blue-600 hover:text-blue-500"
onClick={onClose}
>
Continue Shopping
</button>
</p>
</div>
</div>
)}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</div>
</Dialog>
</Transition.Root>
);
}
Checkout Flow
Step 1: Create Checkout Page
Create app/checkout/page.tsx
:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useCartStore } from '@/stores/cart-store';
import { useAuth } from '@/contexts/auth-context';
import { CheckoutForm } from '@/components/checkout/checkout-form';
import { OrderSummary } from '@/components/checkout/order-summary';
import { PaymentForm } from '@/components/checkout/payment-form';
import { sdk } from '@/lib/api-client';
export default function CheckoutPage() {
const router = useRouter();
const { items, cartId, clearCart } = useCartStore();
const { user } = useAuth();
const [step, setStep] = useState(1);
const [isProcessing, setIsProcessing] = useState(false);
const [shippingData, setShippingData] = useState<any>(null);
const [error, setError] = useState('');
// Redirect if cart is empty
if (items.length === 0) {
router.push('/cart');
return null;
}
const handleShippingSubmit = async (data: any) => {
setShippingData(data);
setStep(2);
};
const handlePaymentSubmit = async (paymentMethod: any) => {
setIsProcessing(true);
setError('');
try {
// Create order
const { data: orderData } = await sdk.createOrder({
input: {
cartId,
shippingAddress: shippingData.shippingAddress,
billingAddress: shippingData.billingAddress || shippingData.shippingAddress,
shippingMethodId: shippingData.shippingMethodId,
paymentMethodId: paymentMethod.id,
notes: shippingData.notes,
}
});
if (orderData?.createOrder?.order) {
// Process payment
const { data: paymentData } = await sdk.processPayment({
orderId: orderData.createOrder.order.id,
paymentMethodId: paymentMethod.id,
});
if (paymentData?.processPayment?.success) {
// Clear cart and redirect to success page
await clearCart();
router.push(`/orders/${orderData.createOrder.order.id}/success`);
} else {
throw new Error(paymentData?.processPayment?.error || 'Payment failed');
}
} else {
throw new Error(orderData?.createOrder?.errors?.[0]?.message || 'Order creation failed');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Checkout failed');
setIsProcessing(false);
}
};
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Checkout</h1>
{error && (
<div className="bg-red-50 text-red-600 p-4 rounded mb-6">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
{/* Progress Steps */}
<div className="flex items-center mb-8">
<div className={`flex items-center ${step >= 1 ? 'text-blue-600' : 'text-gray-400'}`}>
<span className="rounded-full h-8 w-8 flex items-center justify-center border-2 border-current">
1
</span>
<span className="ml-2">Shipping</span>
</div>
<div className="flex-1 h-0.5 bg-gray-300 mx-4" />
<div className={`flex items-center ${step >= 2 ? 'text-blue-600' : 'text-gray-400'}`}>
<span className="rounded-full h-8 w-8 flex items-center justify-center border-2 border-current">
2
</span>
<span className="ml-2">Payment</span>
</div>
</div>
{/* Step Content */}
{step === 1 && (
<CheckoutForm
user={user}
onSubmit={handleShippingSubmit}
isGuest={!user}
/>
)}
{step === 2 && (
<PaymentForm
amount={useCartStore.getState().subtotal}
onSubmit={handlePaymentSubmit}
onBack={() => setStep(1)}
isProcessing={isProcessing}
/>
)}
</div>
{/* Order Summary */}
<div className="lg:col-span-1">
<OrderSummary items={items} />
</div>
</div>
</div>
);
}
Step 2: Create Payment Integration
Create components/checkout/payment-form.tsx
:
'use client';
import { useState, useEffect } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import {
Elements,
CardElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
import { sdk } from '@/lib/api-client';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY!);
interface PaymentFormProps {
amount: number;
onSubmit: (paymentMethod: any) => void;
onBack: () => void;
isProcessing: boolean;
}
function PaymentFormContent({ amount, onSubmit, onBack, isProcessing }: PaymentFormProps) {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState('');
const [clientSecret, setClientSecret] = useState('');
useEffect(() => {
// Create payment intent
createPaymentIntent();
}, [amount]);
const createPaymentIntent = async () => {
try {
const { data } = await sdk.createPaymentIntent({
amount,
currency: 'USD',
});
if (data?.createPaymentIntent?.clientSecret) {
setClientSecret(data.createPaymentIntent.clientSecret);
}
} catch (err) {
setError('Failed to initialize payment');
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements || !clientSecret) {
return;
}
setError('');
const card = elements.getElement(CardElement);
if (!card) return;
const { error, paymentMethod } = await stripe.createPaymentMethod({
type: 'card',
card,
});
if (error) {
setError(error.message || 'Payment failed');
return;
}
// Confirm payment
const { error: confirmError } = await stripe.confirmCardPayment(
clientSecret,
{
payment_method: paymentMethod.id,
}
);
if (confirmError) {
setError(confirmError.message || 'Payment confirmation failed');
return;
}
onSubmit(paymentMethod);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<h2 className="text-xl font-semibold mb-4">Payment Information</h2>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded mb-4">
{error}
</div>
)}
<div className="border rounded p-4">
<CardElement
options={{
style: {
base: {
fontSize: '16px',
color: '#424770',
'::placeholder': {
color: '#aab7c4',
},
},
},
}}
/>
</div>
</div>
<div className="flex gap-4">
<button
type="button"
onClick={onBack}
className="flex-1 border border-gray-300 py-2 px-4 rounded"
disabled={isProcessing}
>
Back
</button>
<button
type="submit"
disabled={!stripe || isProcessing}
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 disabled:opacity-50"
>
{isProcessing ? 'Processing...' : `Pay $${(amount / 100).toFixed(2)}`}
</button>
</div>
</form>
);
}
export function PaymentForm(props: PaymentFormProps) {
return (
<Elements stripe={stripePromise}>
<PaymentFormContent {...props} />
</Elements>
);
}
Performance Optimization
Step 1: Implement Caching Strategy
Create lib/cache.ts
:
import { unstable_cache } from 'next/cache';
import { serverClient } from './api-client';
// Cache product queries for 5 minutes
export const getCachedProducts = unstable_cache(
async (params: any) => {
return serverClient.request(GET_PRODUCTS_QUERY, params);
},
['products'],
{
revalidate: 300, // 5 minutes
tags: ['products'],
}
);
// Cache individual product for 10 minutes
export const getCachedProduct = unstable_cache(
async (slug: string) => {
return serverClient.request(GET_PRODUCT_QUERY, { slug });
},
['product'],
{
revalidate: 600, // 10 minutes
tags: ['product'],
}
);
// Invalidate cache on mutation
export async function revalidateProducts() {
revalidateTag('products');
}
export async function revalidateProduct(slug: string) {
revalidateTag(`product-${slug}`);
}
Step 2: Optimize Images
Create components/optimized-image.tsx
:
'use client';
import Image from 'next/image';
import { useState } from 'react';
interface OptimizedImageProps {
src: string;
alt: string;
width?: number;
height?: number;
priority?: boolean;
className?: string;
sizes?: string;
}
export function OptimizedImage({
src,
alt,
width = 800,
height = 600,
priority = false,
className = '',
sizes = '100vw',
}: OptimizedImageProps) {
const [isLoading, setIsLoading] = useState(true);
// Use image optimization service
const optimizedSrc = src.includes('unifiedcommerce.app')
? `${src}?w=${width}&h=${height}&q=75&fm=webp`
: src;
return (
<div className={`relative overflow-hidden ${className}`}>
<Image
src={optimizedSrc}
alt={alt}
width={width}
height={height}
priority={priority}
sizes={sizes}
className={`
duration-700 ease-in-out
${isLoading ? 'scale-110 blur-lg' : 'scale-100 blur-0'}
`}
onLoadingComplete={() => setIsLoading(false)}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
/>
</div>
);
}
Step 3: Implement Lazy Loading
Create hooks/use-intersection-observer.ts
:
import { useEffect, useRef, useState } from 'react';
interface UseIntersectionObserverProps {
threshold?: number;
root?: Element | null;
rootMargin?: string;
}
export function useIntersectionObserver({
threshold = 0.1,
root = null,
rootMargin = '0px',
}: UseIntersectionObserverProps = {}) {
const [entry, setEntry] = useState<IntersectionObserverEntry>();
const [node, setNode] = useState<Element | null>(null);
const observer = useRef<IntersectionObserver | null>(null);
useEffect(() => {
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver(
([entry]) => setEntry(entry),
{ threshold, root, rootMargin }
);
if (node) observer.current.observe(node);
return () => {
if (observer.current) observer.current.disconnect();
};
}, [node, threshold, root, rootMargin]);
return { setNode, entry, isIntersecting: entry?.isIntersecting };
}
// Usage in component
export function LazyProductGrid({ products }: { products: Product[] }) {
const { setNode, isIntersecting } = useIntersectionObserver();
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
if (isIntersecting && !isLoaded) {
setIsLoaded(true);
}
}, [isIntersecting, isLoaded]);
return (
<div ref={setNode}>
{isLoaded ? (
<ProductGrid products={products} />
) : (
<div className="h-96 bg-gray-100 animate-pulse" />
)}
</div>
);
}
Troubleshooting
Common Issues and Solutions
1. Authentication Errors
Problem: Getting 401 Unauthorized errors Solution:
// Check if token is expired
const checkTokenExpiry = (token: string) => {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000 > Date.now();
} catch {
return false;
}
};
// Refresh token if expired
if (!checkTokenExpiry(token)) {
await refreshToken();
}
2. Cart Persistence Issues
Problem: Cart items lost on refresh Solution:
// Sync cart on app mount
useEffect(() => {
const syncCartOnMount = async () => {
const cartId = localStorage.getItem('cartId');
if (cartId) {
await cartStore.syncCart();
}
};
syncCartOnMount();
}, []);
3. Slow Product Loading
Problem: Product pages loading slowly Solution:
// Implement parallel data fetching
const [products, categories, featured] = await Promise.all([
getCachedProducts({ first: 20 }),
getCachedCategories(),
getFeaturedProducts(),
]);
// Use React Suspense
<Suspense fallback={<ProductSkeleton />}>
<ProductGrid products={products} />
</Suspense>
4. Payment Failures
Problem: Stripe payment failing Solution:
// Add comprehensive error handling
try {
const result = await stripe.confirmCardPayment(clientSecret);
if (result.error) {
switch (result.error.code) {
case 'card_declined':
showError('Your card was declined. Please try another card.');
break;
case 'insufficient_funds':
showError('Insufficient funds. Please try another payment method.');
break;
default:
showError(result.error.message || 'Payment failed');
}
}
} catch (error) {
console.error('Payment error:', error);
showError('An unexpected error occurred. Please try again.');
}
5. SEO Issues
Problem: Poor SEO performance Solution:
// Add structured data
export function generateJsonLd(product: Product) {
return {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.images[0]?.url,
offers: {
'@type': 'Offer',
price: (product.price / 100).toFixed(2),
priceCurrency: product.currency,
availability: product.inventory.available > 0
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
};
}
// Add to page
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(generateJsonLd(product)),
}}
/>
Deployment
Step 1: Environment Setup
Create .env.production
:
NEXT_PUBLIC_UC_ENDPOINT=https://gateway.unifiedcommerce.app/graphql
NEXT_PUBLIC_UC_API_KEY=your_production_api_key
NEXT_PUBLIC_UC_MERCHANT_ID=merchant_prod_123
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
Step 2: Build Optimization
Update next.config.js
:
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ['cdn.unifiedcommerce.app'],
formats: ['image/avif', 'image/webp'],
},
experimental: {
optimizeCss: true,
},
compress: true,
poweredByHeader: false,
// Enable SWC minification
swcMinify: true,
// Bundle analyzer
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.alias = {
...config.resolve.alias,
'@sentry/node': '@sentry/browser',
};
}
return config;
},
};
module.exports = nextConfig;
Step 3: Deploy to Vercel
# Install Vercel CLI
npm i -g vercel
# Deploy to production
vercel --prod
# Set environment variables
vercel env add NEXT_PUBLIC_UC_API_KEY production
vercel env add STRIPE_SECRET_KEY production
Best Practices
- Error Boundaries: Implement error boundaries for graceful failures
- Loading States: Always show loading indicators for async operations
- Optimistic Updates: Update UI immediately for better UX
- Image Optimization: Use Next.js Image component with proper sizes
- Code Splitting: Lazy load components and routes
- API Caching: Cache API responses appropriately
- Security: Never expose sensitive keys in client code
- Analytics: Implement tracking for user behavior
- A/B Testing: Use feature flags for gradual rollouts
- Monitoring: Set up error tracking and performance monitoring
Next Steps
- Implement advanced features (wishlists, reviews, recommendations)
- Add internationalization (i18n) support
- Implement Progressive Web App (PWA) features
- Add real-time inventory updates via WebSockets
- Implement advanced search with Algolia/Elasticsearch
- Add social commerce features
- Implement subscription/recurring payments
- Add multi-vendor marketplace capabilities