DEV Community

Nadim Chowdhury
Nadim Chowdhury

Posted on

Building a Production-Ready E-Commerce Platform with NestJS

Look, I'll be honest with you. After spending the last three months building an e-commerce platform from scratch, I've learned more about architecture, scalability, and UI/UX than I did in my entire bootcamp. And honestly? It wasn't as scary as I thought it would be.

Today I'm breaking down everything—the complete file structure, system design, architecture decisions, and the modern UI approach that actually makes sense. No fluff, just the real stuff that worked.

Why NestJS? (And Why You Should Care)

I went with NestJS over Express because, frankly, I was tired of the "anything goes" chaos of Express. Don't get me wrong, Express is fantastic, but for an e-commerce platform where you need clean separation of concerns, dependency injection, and built-in TypeScript support? NestJS just clicks.

The modularity alone saved me weeks of refactoring. Plus, the built-in decorators and guards made authentication and authorization feel less like pulling teeth.

The Big Picture: System Architecture

Before we dive into code, let's talk architecture. Here's what I built:

The Stack:

  • Backend: NestJS with TypeScript
  • Database: PostgreSQL (with TypeORM)
  • Cache: Redis
  • File Storage: AWS S3
  • Payment: Stripe
  • Search: Elasticsearch
  • Email: SendGrid
  • Frontend: Next.js 14 with App Router
  • State Management: Zustand
  • UI: Tailwind CSS + Shadcn/ui

The Architecture Pattern:
I went with a modular monolith approach, not microservices. Why? Because premature optimization is the root of all evil. Start simple, scale when needed. Each module is isolated enough that if you need to extract it into a microservice later, you can.

High-Level System Design

┌─────────────────┐
│   CDN (Images)  │
└────────┬────────┘
         │
┌────────▼────────────────────────────────────┐
│          Load Balancer (NGINX)              │
└────────┬────────────────────────────────────┘
         │
    ┌────┴─────┐
    │          │
┌───▼──┐   ┌───▼──┐
│ App  │   │ App  │  (Horizontal Scaling)
│ Node │   │ Node │
└───┬──┘   └───┬──┘
    │          │
    └────┬─────┘
         │
    ┌────▼─────────────────────────┐
    │                              │
┌───▼────┐  ┌─────────┐  ┌────────▼──┐
│PostGres│  │  Redis  │  │Elasticsearch│
└────────┘  └─────────┘  └───────────┘
Enter fullscreen mode Exit fullscreen mode

Project Structure That Actually Makes Sense

Here's the folder structure I settled on after multiple iterations:

ecommerce-backend/
├── src/
│   ├── main.ts
│   ├── app.module.ts
│   │
│   ├── common/
│   │   ├── decorators/
│   │   │   ├── current-user.decorator.ts
│   │   │   ├── roles.decorator.ts
│   │   │   └── public.decorator.ts
│   │   ├── filters/
│   │   │   ├── http-exception.filter.ts
│   │   │   └── all-exceptions.filter.ts
│   │   ├── guards/
│   │   │   ├── jwt-auth.guard.ts
│   │   │   └── roles.guard.ts
│   │   ├── interceptors/
│   │   │   ├── logging.interceptor.ts
│   │   │   └── transform.interceptor.ts
│   │   ├── pipes/
│   │   │   └── validation.pipe.ts
│   │   ├── middleware/
│   │   │   ├── logger.middleware.ts
│   │   │   └── rate-limit.middleware.ts
│   │   └── interfaces/
│   │       └── pagination.interface.ts
│   │
│   ├── config/
│   │   ├── database.config.ts
│   │   ├── jwt.config.ts
│   │   ├── redis.config.ts
│   │   └── aws.config.ts
│   │
│   ├── modules/
│   │   ├── auth/
│   │   │   ├── auth.module.ts
│   │   │   ├── auth.controller.ts
│   │   │   ├── auth.service.ts
│   │   │   ├── strategies/
│   │   │   │   ├── jwt.strategy.ts
│   │   │   │   └── local.strategy.ts
│   │   │   └── dto/
│   │   │       ├── login.dto.ts
│   │   │       └── register.dto.ts
│   │   │
│   │   ├── users/
│   │   │   ├── users.module.ts
│   │   │   ├── users.controller.ts
│   │   │   ├── users.service.ts
│   │   │   ├── entities/
│   │   │   │   └── user.entity.ts
│   │   │   └── dto/
│   │   │       ├── create-user.dto.ts
│   │   │       └── update-user.dto.ts
│   │   │
│   │   ├── products/
│   │   │   ├── products.module.ts
│   │   │   ├── products.controller.ts
│   │   │   ├── products.service.ts
│   │   │   ├── entities/
│   │   │   │   ├── product.entity.ts
│   │   │   │   ├── category.entity.ts
│   │   │   │   └── product-variant.entity.ts
│   │   │   └── dto/
│   │   │       ├── create-product.dto.ts
│   │   │       ├── update-product.dto.ts
│   │   │       └── filter-product.dto.ts
│   │   │
│   │   ├── cart/
│   │   │   ├── cart.module.ts
│   │   │   ├── cart.controller.ts
│   │   │   ├── cart.service.ts
│   │   │   ├── entities/
│   │   │   │   ├── cart.entity.ts
│   │   │   │   └── cart-item.entity.ts
│   │   │   └── dto/
│   │   │       ├── add-to-cart.dto.ts
│   │   │       └── update-cart-item.dto.ts
│   │   │
│   │   ├── orders/
│   │   │   ├── orders.module.ts
│   │   │   ├── orders.controller.ts
│   │   │   ├── orders.service.ts
│   │   │   ├── entities/
│   │   │   │   ├── order.entity.ts
│   │   │   │   └── order-item.entity.ts
│   │   │   └── dto/
│   │   │       ├── create-order.dto.ts
│   │   │       └── update-order-status.dto.ts
│   │   │
│   │   ├── payments/
│   │   │   ├── payments.module.ts
│   │   │   ├── payments.controller.ts
│   │   │   ├── payments.service.ts
│   │   │   ├── stripe.service.ts
│   │   │   └── dto/
│   │   │       └── create-payment-intent.dto.ts
│   │   │
│   │   ├── reviews/
│   │   │   ├── reviews.module.ts
│   │   │   ├── reviews.controller.ts
│   │   │   ├── reviews.service.ts
│   │   │   ├── entities/
│   │   │   │   └── review.entity.ts
│   │   │   └── dto/
│   │   │       ├── create-review.dto.ts
│   │   │       └── update-review.dto.ts
│   │   │
│   │   ├── search/
│   │   │   ├── search.module.ts
│   │   │   ├── search.service.ts
│   │   │   └── elasticsearch.service.ts
│   │   │
│   │   ├── notifications/
│   │   │   ├── notifications.module.ts
│   │   │   ├── notifications.service.ts
│   │   │   └── email.service.ts
│   │   │
│   │   └── upload/
│   │       ├── upload.module.ts
│   │       ├── upload.controller.ts
│   │       └── upload.service.ts
│   │
│   └── database/
│       ├── migrations/
│       └── seeds/
│
├── test/
│   ├── e2e/
│   └── unit/
│
├── .env.example
├── .eslintrc.js
├── .prettierrc
├── nest-cli.json
├── package.json
├── tsconfig.json
└── docker-compose.yml
Enter fullscreen mode Exit fullscreen mode

Frontend Structure (Next.js 14)

ecommerce-frontend/
├── src/
│   ├── app/
│   │   ├── (auth)/
│   │   │   ├── login/
│   │   │   │   └── page.tsx
│   │   │   └── register/
│   │   │       └── page.tsx
│   │   ├── (shop)/
│   │   │   ├── products/
│   │   │   │   ├── page.tsx
│   │   │   │   └── [id]/
│   │   │   │       └── page.tsx
│   │   │   ├── cart/
│   │   │   │   └── page.tsx
│   │   │   ├── checkout/
│   │   │   │   └── page.tsx
│   │   │   └── orders/
│   │   │       └── page.tsx
│   │   ├── (dashboard)/
│   │   │   └── admin/
│   │   │       ├── layout.tsx
│   │   │       ├── products/
│   │   │       ├── orders/
│   │   │       └── analytics/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── globals.css
│   │
│   ├── components/
│   │   ├── ui/
│   │   │   ├── button.tsx
│   │   │   ├── card.tsx
│   │   │   ├── input.tsx
│   │   │   ├── dialog.tsx
│   │   │   └── ... (shadcn components)
│   │   ├── layout/
│   │   │   ├── header.tsx
│   │   │   ├── footer.tsx
│   │   │   └── sidebar.tsx
│   │   ├── products/
│   │   │   ├── product-card.tsx
│   │   │   ├── product-grid.tsx
│   │   │   └── product-filters.tsx
│   │   ├── cart/
│   │   │   ├── cart-item.tsx
│   │   │   └── cart-summary.tsx
│   │   └── checkout/
│   │       ├── shipping-form.tsx
│   │       └── payment-form.tsx
│   │
│   ├── lib/
│   │   ├── api.ts
│   │   ├── utils.ts
│   │   └── validators.ts
│   │
│   ├── hooks/
│   │   ├── use-cart.ts
│   │   ├── use-auth.ts
│   │   └── use-products.ts
│   │
│   ├── store/
│   │   ├── auth.store.ts
│   │   ├── cart.store.ts
│   │   └── ui.store.ts
│   │
│   └── types/
│       ├── product.types.ts
│       ├── user.types.ts
│       └── order.types.ts
│
├── public/
│   ├── images/
│   └── icons/
│
├── .env.local
├── next.config.js
├── tailwind.config.js
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

The Database Schema Design

I spent way too much time on this, but it was worth it. Here's the core schema:

Users Table:

  • id (UUID)
  • email (unique)
  • password (hashed)
  • firstName
  • lastName
  • role (enum: customer, admin)
  • emailVerified
  • createdAt, updatedAt

Products Table:

  • id (UUID)
  • name
  • slug (unique, indexed)
  • description
  • price
  • compareAtPrice
  • costPerItem
  • categoryId (FK)
  • stock
  • sku
  • images (JSON array)
  • isActive
  • createdAt, updatedAt

Categories Table:

  • id (UUID)
  • name
  • slug (unique)
  • parentId (self-referential FK for nested categories)
  • image

Orders Table:

  • id (UUID)
  • userId (FK)
  • orderNumber (unique)
  • status (enum: pending, processing, shipped, delivered, cancelled)
  • subtotal
  • tax
  • shipping
  • total
  • shippingAddress (JSON)
  • billingAddress (JSON)
  • paymentStatus
  • paymentIntentId
  • createdAt, updatedAt

OrderItems Table:

  • id (UUID)
  • orderId (FK)
  • productId (FK)
  • quantity
  • price (snapshot at purchase time)
  • productSnapshot (JSON - store product details)

Cart & CartItems Tables:
Similar structure to Orders, but for active carts.

Reviews Table:

  • id (UUID)
  • productId (FK)
  • userId (FK)
  • rating (1-5)
  • title
  • content
  • verified (boolean - did they buy it?)
  • createdAt

Key Features Implementation

1. Authentication Flow

I implemented JWT-based authentication with refresh tokens. The access token expires in 15 minutes, refresh token in 7 days. Stored the refresh token in httpOnly cookies for security.

// auth.service.ts snippet
async login(loginDto: LoginDto) {
  const user = await this.validateUser(loginDto.email, loginDto.password);

  const tokens = await this.generateTokens(user);

  await this.storeRefreshToken(user.id, tokens.refreshToken);

  return {
    user: this.sanitizeUser(user),
    ...tokens
  };
}
Enter fullscreen mode Exit fullscreen mode

2. Product Search with Elasticsearch

This was a game-changer. Instead of slow SQL LIKE queries, Elasticsearch gives you fuzzy search, typo tolerance, and instant results.

async searchProducts(query: string, filters: any) {
  return await this.elasticsearchService.search({
    index: 'products',
    body: {
      query: {
        bool: {
          must: [
            {
              multi_match: {
                query,
                fields: ['name^3', 'description', 'category'],
                fuzziness: 'AUTO'
              }
            }
          ],
          filter: [
            { term: { isActive: true } },
            { range: { price: { gte: filters.minPrice, lte: filters.maxPrice } } }
          ]
        }
      }
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

3. Cart Management with Redis

Carts are ephemeral. Why hit the database every time? I used Redis for active carts and only persist to PostgreSQL when checking out.

4. Payment Integration

Stripe made this surprisingly easy. The key is creating a PaymentIntent on the backend, never exposing your secret key to the frontend.

5. Image Upload Flow

Images go directly to S3. I generate presigned URLs on the backend so the frontend can upload directly, bypassing my server for large files.

The UI/UX Philosophy

Here's where it gets interesting. I went for a clean, minimal aesthetic inspired by Vercel and Linear. The key principles:

Typography:

  • Inter for UI elements
  • Clear hierarchy with consistent sizing (text-sm, text-base, text-lg, text-xl)
  • Generous line-height for readability

Color Palette:

  • Neutral base (slate-50 to slate-900)
  • Single accent color (indigo for CTAs)
  • Subtle hover states
  • Dark mode with proper contrast

Spacing:

  • Consistent 8px grid system
  • Generous white space
  • Card-based layouts with subtle shadows

Components:

  • Used Shadcn/ui for consistency
  • Customized with Tailwind
  • Smooth transitions (duration-200, duration-300)
  • Skeleton loaders for async states

Product Cards:

<div className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white transition-all hover:shadow-lg">
  <div className="aspect-square overflow-hidden">
    <Image 
      src={product.image} 
      className="object-cover transition-transform group-hover:scale-105" 
    />
  </div>
  <div className="p-4">
    <h3 className="font-medium text-slate-900">{product.name}</h3>
    <p className="mt-1 text-sm text-slate-500">{product.category}</p>
    <div className="mt-3 flex items-center justify-between">
      <span className="text-lg font-semibold">${product.price}</span>
      <Button size="sm">Add to Cart</Button>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Performance Optimizations

Backend:

  • Database query optimization with proper indexes
  • Redis caching for frequently accessed data (products, categories)
  • Rate limiting on all public endpoints
  • Compression middleware
  • Database connection pooling

Frontend:

  • Next.js Image optimization
  • Route-based code splitting
  • React Server Components where possible
  • Optimistic UI updates
  • Virtual scrolling for long product lists (react-window)
  • Debounced search inputs

Deployment Strategy

I went with:

  • Backend: Railway / Render
  • Frontend: Vercel
  • Database: Supabase (managed PostgreSQL)
  • Redis: Upstash
  • CDN: Cloudflare for static assets

Docker compose for local development made life easier:

version: '3.8'
services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_DB: ecommerce
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: password
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  elasticsearch:
    image: elasticsearch:8.9.0
    environment:
      - discovery.type=single-node
    ports:
      - "9200:9200"
Enter fullscreen mode Exit fullscreen mode

Testing Strategy

I'm not going to lie, testing was the hardest part to stay disciplined about. But here's what worked:

  • Unit tests for services (Jest)
  • E2E tests for critical flows (login, checkout) with Supertest
  • Frontend testing with React Testing Library
  • Manual testing with a checklist before each deployment

What I'd Do Differently

If I started over tomorrow:

  1. Set up logging and monitoring from day one. I added Sentry and Winston logs way too late.

  2. Invest in proper error messages. Generic "Something went wrong" is lazy. Be specific.

  3. Document as you go. I had to reverse-engineer my own code weeks later.

  4. Use feature flags. Would've made rolling out new features way less scary.

  5. Set up CI/CD earlier. GitHub Actions saved me so much time once I finally configured it.

The Numbers

After 3 months:

  • ~15,000 lines of backend code
  • ~8,000 lines of frontend code
  • 47 database tables (including join tables)
  • 89 API endpoints
  • Average response time: 120ms
  • Lighthouse score: 94/100

Final Thoughts

Building an e-commerce platform is a marathon, not a sprint. Start with the core features (auth, products, cart, checkout), make sure those work flawlessly, then iterate.

The architecture I shared is production-ready and can scale to thousands of concurrent users. The key is starting with a solid foundation—clean code, proper separation of concerns, and thinking about performance from the beginning.

Don't overcomplicate things. You don't need microservices on day one. You don't need Kubernetes. Build something that works, deploy it, get feedback, and improve.

The code is the easy part. The hard part is understanding your users, designing intuitive flows, and building something people actually want to use.

Now stop reading and go build something.


That's a wrap 🎁

Now go touch some code 👨‍💻

Catch me here → LinkedIn | GitHub | YouTube

Top comments (0)