Next.js E-commerce Architecture: Building NeedThisDone.com
Next.js E-commerce Architecture: Building NeedThisDone.com
When I started building NeedThisDone.com in November 2025, I knew I didn't want just another consulting website. I needed a platform that could handle service bookings, e-commerce products, content management, and an AI-powered chatbot. Two and a half months and 1,300+ commits later, here's the architecture that powers it all.
Project Requirements
The platform needed to support:
- Consulting services - appointment booking, customer dashboard, order management
- E-commerce - website packages, add-ons, and subscriptions with unified checkout
- Content management - blog posts, case studies, inline editing for marketing pages
- AI chatbot - contextual help using pgvector for semantic search
- Admin dashboards - analytics, customer management, email campaigns
Traditional monolithic CMSs like WordPress or Shopify wouldn't cut it. I needed composable architecture with headless services that could scale independently.
High-Level Architecture
Here's how the pieces fit together:
┌─────────────────────────────────────────────────────────────────────┐
│ VERCEL EDGE NETWORK │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Next.js 14 App Router │ │
│ │ │ │
│ │ • 74 API Routes (server actions, webhooks) │ │
│ │ • 160+ React Components (Server + Client) │ │
│ │ • ISR for product/blog pages │ │
│ └─────┬─────────────────┬─────────────────┬──────────────────────┘ │
│ │ │ │ │
└────────┼─────────────────┼─────────────────┼────────────────────────┘
│ │ │
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐
│ Medusa │◄─────►│ Supabase │◄───►│ Stripe │
│Railway │ │PostgreSQL│ │ Payments │
└────┬───┘ └────┬─────┘ └─────┬────┘
│ │ │
│ ┌────▼─────┐ │
│ │ pgvector │ │
│ │AI Context│ │
│ └──────────┘ │
│ │
└───────────────┬───────────────────┘
▼
┌─────────┐
│ Redis │
│ Upstash │
└─────────┘
Request Flow Example (Add to Cart):
- User clicks "Add to Cart" on product page
- Next.js Server Component fetches product from Medusa API
- Client-side CartContext calls
/api/cartendpoint - API route updates Medusa cart + Supabase analytics
- Redis circuit breaker prevents duplicate requests
- Response returns to client with updated cart state
Data Model Design: What Lives Where
One of the biggest decisions was choosing where each piece of data should live. Here's what I landed on:
| Data Type | Storage | Why |
|---|---|---|
| Products, Orders, Cart | Medusa (PostgreSQL on Railway) | Headless commerce engine handles inventory, pricing, tax calculation |
| Users, Profiles, Analytics | Supabase PostgreSQL | Auth, customer data, order history, spending trends |
| Payments, Subscriptions | Stripe | PCI compliance, recurring billing, webhook orchestration |
| Blog Posts, FAQs, Reviews | Supabase | CMS content with full-text search and filtering |
| AI Chat Context | Supabase pgvector | Semantic search using OpenAI embeddings |
| Request Deduplication | Redis (Upstash) | Sub-50ms lookups for duplicate prevention |
Key Principle: Each service owns its domain. Medusa doesn't know about blog posts. Supabase doesn't manage cart state. Stripe is the source of truth for payment status.
TypeScript Types Across Boundaries
To keep this type-safe, I defined shared interfaces:
// app/lib/types/product.ts
export interface Product {
id: string;
title: string;
description: string;
variants: ProductVariant[];
metadata: {
type: 'package' | 'addon' | 'service' | 'subscription';
features: string[];
deposit_percent?: number;
};
}
// app/lib/types/order.ts
export interface Order {
id: string;
customer_id: string;
medusa_order_id: string; // Links to Medusa
stripe_payment_intent?: string; // Links to Stripe
status: 'pending' | 'completed' | 'cancelled';
total: number;
created_at: string;
}
These types act as contracts between services. When Medusa returns product data, I validate it against Product. When Stripe webhooks fire, I map payment data to Order.
Key Design Decisions
Server vs Client Components
Next.js 14's App Router defaults to Server Components. I use this aggressively:
// app/shop/page.tsx - Server Component (default)
import { medusaClient } from '@/lib/medusa-client';
export default async function ShopPage() {
// Fetch products server-side - no loading spinner needed
const { products } = await medusaClient.products.list();
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
Client Components only when needed:
- Cart state management (CartContext)
- Interactive forms (checkout, contact)
- Animations and client-side routing
This keeps the initial JavaScript bundle under 150KB gzipped.
Unified Cart for All Product Types
Early on, I had separate cart systems for services vs products. Big mistake. Now everything flows through Medusa:
// app/context/CartContext.tsx
export function CartProvider({ children }: { children: React.ReactNode }) {
const [cartId, setCartId] = useState<string | null>(null);
const addItem = async (variantId: string, quantity: number) => {
if (!cartId) {
// Create cart on first item
const newCart = await medusaClient.carts.create();
setCartId(newCart.id);
localStorage.setItem('medusa_cart_id', newCart.id);
}
// Add item (works for packages, addons, services, subscriptions)
await medusaClient.carts.lineItems.create(cartId, {
variant_id: variantId,
quantity,
});
};
// ... remove, update quantity, etc.
}
Whether it's a website package, a subscription, or an add-on, the flow is identical. Medusa handles the complexity of tax calculation, shipping, and deposit splitting.
Checkout Flow: Handling Subscriptions
Subscriptions were tricky. Medusa creates orders, but Stripe manages recurring billing. The webhook choreography looks like this:
User clicks "Checkout" with subscription item
↓
Next.js creates Medusa order
↓
Medusa webhook → Next.js /api/webhooks/medusa
↓
Next.js creates Stripe subscription (not one-time payment)
↓
Stripe webhook → Next.js /api/webhooks/stripe
↓
Update Supabase: subscription_id, next_billing_date
The key insight: Medusa creates the initial order record, Stripe owns the recurring schedule. Supabase tracks which customer has which subscription.
Reliability Patterns
With 74 API routes and external dependencies on Medusa, Stripe, and Supabase, failures are inevitable. Here's how I handle them:
Circuit Breaker Pattern
Redis circuit breaker prevents cascading failures when Supabase goes down:
// app/lib/redis.ts
export async function circuitBreakerGet<T>(
key: string,
fallback: T
): Promise<T> {
try {
const cached = await redis.get(key);
return cached ? JSON.parse(cached) : fallback;
} catch (error) {
console.error('Redis circuit breaker tripped:', error);
return fallback; // Graceful degradation
}
}
I cover this in depth in Building a Circuit Breaker Pattern in Node.js.
Request Deduplication
SHA-256 fingerprinting prevents double-submit bugs:
// app/lib/request-dedup.ts
const generateFingerprint = (body: unknown): string => {
const normalized = JSON.stringify(body, Object.keys(body).sort());
return createHash('sha256').update(normalized).digest('hex');
};
See Request Deduplication: Preventing Double Submissions for implementation details.
Connection Pooling
Singleton Supabase client handles 100+ concurrent requests without connection pool exhaustion:
// app/lib/supabase-client.ts
let supabaseInstance: SupabaseClient | null = null;
export function getSupabaseClient() {
if (!supabaseInstance) {
supabaseInstance = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
return supabaseInstance;
}
Performance Optimizations
Incremental Static Regeneration (ISR)
Product and blog pages use ISR with 60-second revalidation:
// app/shop/[productId]/page.tsx
export const revalidate = 60; // Regenerate every 60 seconds
export default async function ProductPage({
params,
}: {
params: { productId: string };
}) {
const product = await medusaClient.products.retrieve(params.productId);
return <ProductDetail product={product} />;
}
This gives me near-static performance with dynamic data.
Lazy Loading Heavy Components
Admin dashboards use dynamic imports to avoid bloating the main bundle:
// app/admin/page.tsx
import dynamic from 'next/dynamic';
const AnalyticsDashboard = dynamic(
() => import('@/components/admin/AnalyticsDashboard'),
{ ssr: false }
);
export default function AdminPage() {
return <AnalyticsDashboard />;
}
Lessons Learned
What Worked:
- Headless architecture - swapping Medusa for Shopify would take a day, not weeks
- Type safety - TypeScript caught 200+ bugs before production
- Server Components - massive performance win, initial load under 2 seconds
- Unified cart - one checkout flow for all product types
What I'd Change:
- Start with monorepo - managing Medusa separately on Railway adds deployment friction
- More aggressive caching - Redis could cache more Medusa API calls
- Earlier investment in E2E tests - I have 71 test files now, wish I started with that discipline
Want This for Your Business?
This architecture supports a consulting platform with e-commerce, CMS, and AI chat. The same patterns work for SaaS products, marketplaces, and membership sites.
If you need a custom platform that scales, I can help. I build production-grade apps with Next.js, headless commerce, and modern DevOps practices.
View My Services • See Pricing • Get in Touch
Let's build something that grows with your business.
Need Help Getting Things Done?
Whether it's a project you've been putting off or ongoing support you need, we're here to help.