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│
└────────┘ └─────────┘ └───────────┘
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
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
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
};
}
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 } } }
]
}
}
}
});
}
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>
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"
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:
Set up logging and monitoring from day one. I added Sentry and Winston logs way too late.
Invest in proper error messages. Generic "Something went wrong" is lazy. Be specific.
Document as you go. I had to reverse-engineer my own code weeks later.
Use feature flags. Would've made rolling out new features way less scary.
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 👨💻
Top comments (0)