Skip to main content
← Back to Blog
Case Study

Next.js E-commerce Architecture: Building NeedThisDone.com

A
Abe Reyes
February 6, 20267 min read

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):

  1. User clicks "Add to Cart" on product page
  2. Next.js Server Component fetches product from Medusa API
  3. Client-side CartContext calls /api/cart endpoint
  4. API route updates Medusa cart + Supabase analytics
  5. Redis circuit breaker prevents duplicate requests
  6. 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 TypeStorageWhy
Products, Orders, CartMedusa (PostgreSQL on Railway)Headless commerce engine handles inventory, pricing, tax calculation
Users, Profiles, AnalyticsSupabase PostgreSQLAuth, customer data, order history, spending trends
Payments, SubscriptionsStripePCI compliance, recurring billing, webhook orchestration
Blog Posts, FAQs, ReviewsSupabaseCMS content with full-text search and filtering
AI Chat ContextSupabase pgvectorSemantic search using OpenAI embeddings
Request DeduplicationRedis (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 ServicesSee PricingGet 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.